Skip to content
Merged
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
3 changes: 2 additions & 1 deletion .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,8 @@ ladder, cheapest first:
Pull requests also run `cloudflare-check`, the www E2E suite, and `docker-smoke`, so a green
`just ci` can still leave a red PR check. `just ci-full` covers them; it takes tens of minutes and
needs Docker. Only CodeQL, Trivy, Scorecard, and dependency review stay GitHub-only: they scan built
images or the repository itself.
images or the repository itself. The CodSpeed benchmark comparison also runs only on GitHub, since
it needs CodSpeed's instrumented runner; `just backend/bench` runs the same benchmarks once as tests.

On a pull request, the subrepo jobs, their Docker smoke legs, and the CodeQL languages run only for
the subrepos the diff touches (`scripts/ci_changed_areas.py`). A change outside the four subrepos
Expand Down
65 changes: 65 additions & 0 deletions .github/workflows/codspeed.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json

# Backend CPU benchmarks (backend/tests/benchmarks), measured by CodSpeed in its CPU
# simulation mode: it counts instructions, so results hold steady across hosted runners.
# Lives outside ci.yml so the `id-token: write` the upload needs stays on this one job.
name: CodSpeed

on:
# Every run on main is the baseline a pull request is compared against. A PR is
# compared with the newest main commit that has a run, so main can skip commits
# that do not touch the backend too.
push:
branches: [main]
paths:
- backend/**
- .github/actions/setup-runtime/**
- .github/workflows/codspeed.yml
- .tool-versions
pull_request:
branches: [main]
paths:
- backend/**
- .github/actions/setup-runtime/**
- .github/workflows/codspeed.yml
- .tool-versions
# Lets CodSpeed backfill results for older main commits.
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

permissions: {}

jobs:
benchmarks:
name: Backend Benchmarks
runs-on: ubuntu-26.04
timeout-minutes: 20
permissions:
contents: read
# The upload authenticates with OIDC. Pull requests from forks get no token, and
# CodSpeed accepts their uploads without one because the repository is public.
id-token: write
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Set up runtime
uses: ./.github/actions/setup-runtime
with:
setup-python: "true"
uv-cache-dependency-glob: backend/uv.lock

- name: Install dependencies
run: just backend/install

- name: Run benchmarks
uses: CodSpeedHQ/action@78102a468f96d8e0ccb92d0ec49425753fc42062 # v5.6.0
with:
mode: simulation
working-directory: backend
run: uv run pytest tests/benchmarks --codspeed
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
[![OpenSSF Best Practices](https://www.bestpractices.dev/projects/14492/badge)](https://www.bestpractices.dev/projects/14492)
[![FAIR checklist badge](https://fairsoftwarechecklist.net/badge.svg)](https://fairsoftwarechecklist.net/v0.2?f=31&a=32113&i=22322&r=123)
[![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](.github/CODE_OF_CONDUCT.md)
[![CodSpeed](https://img.shields.io/endpoint?url=https://codspeed.io/badge.json)](https://app.codspeed.io/CMLPlatform/r9lab?utm_source=badge)
[![Deployed](https://img.shields.io/website?url=https%3A%2F%2Fr9lab.io&label=website)](https://r9lab.io)

R9lab is an open-source research platform for collecting and publicly viewing data on the
Expand Down
1 change: 1 addition & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ just test # run all tests
just test-unit # fast unit tests
just test-cov # tests with coverage
just refresh-disposable-email-domains # update the committed disposable-email fallback list
just bench # run the CPU benchmarks once (CodSpeed measures them in CI)
just perf-baseline # run the k6 baseline suite and export a JSON summary (output under reports/performance/ is gitignored)
just migrate # apply migrations
just migrate-create "description" # create a migration from the current model changes
Expand Down
5 changes: 5 additions & 0 deletions backend/justfile
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ test-ci:
mkdir -p reports/coverage
{{ pytest }} tests/unit tests/integration -n auto --dist=loadgroup --durations=20 --junitxml=reports/junit.xml --cov --cov-report=xml --cov-report=term

# Run the CPU benchmarks once as plain tests. CodSpeed counts their instructions in CI;
# `just bench --codspeed` times them locally instead.
bench *args:
{{ pytest }} tests/benchmarks {{ args }}

# Mutation testing against one throwaway Postgres; the full app takes hours. Narrow
# it with mutant globs: `just mutation 'app.core.images*'`. Survivors never fail the
# run; it lists functions with more survivors than tests/mutation-baseline.txt.
Expand Down
3 changes: 3 additions & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@
# Mocking and assertions
"dirty-equals>=0.8.0", # Flexible assertions for dynamic data
"pytest-mock>=3.14.0", # Better mocking with cleaner pytest integration

# CPU benchmarks (tests/benchmarks), measured by CodSpeed in CI
"pytest-codspeed>=5.0.3",
]
mutation = [
"mutmut>=3.8.0",
Expand Down
7 changes: 7 additions & 0 deletions backend/tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ cache wiring, file cleanup, migration behavior. One subsystem per test.
A few multi-step journeys that cross feature boundaries, such as authenticate -> mutate -> fetch or
camera setup -> record -> persist. Keep them sparse; they are slower than the other tiers.

### `tests/benchmarks/`

CPU benchmarks for backend hot paths (image uploads, product create validation, the component tree
read, and the export), written with `pytest-codspeed`. No database or network: they start from rows
built in memory. The CodSpeed workflow measures them on every pull request that touches the
backend. `just bench` runs them once as plain tests, and so does `just test`.

## Assertions

- Assert what tells the paths apart, not just the outcome. Several paths often converge on one
Expand Down
1 change: 1 addition & 0 deletions backend/tests/benchmarks/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""CPU benchmarks for backend hot paths, measured by CodSpeed (pytest-codspeed)."""
205 changes: 205 additions & 0 deletions backend/tests/benchmarks/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
"""Shared inputs for the benchmark suite: built once per session, with no database or network.

Every input is deterministic, so CodSpeed's instruction counts change only when the code does.
"""

import io
import random
import uuid
from collections import deque
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import TYPE_CHECKING

import pytest
from PIL import Image as PILImage
from PIL.ExifTags import IFD

from app.api.auth.models import User
from app.api.common.models.enums import Unit
from app.api.data_collection.crud.product_tree_queries import COMPONENT_TREE_MAX_COMPONENTS
from app.api.data_collection.models.product import MaterialProductLink, Product
from app.api.reference_data.models import Material, ProductType

if TYPE_CHECKING:
from typing import Any

_TIMESTAMP = datetime(2025, 1, 1, tzinfo=UTC)

# A 12 MP phone photo, the most common upload. At this size thumbnail generation takes
# the JPEG draft path (decoding at half scale for the 1600 px width).
PHOTO_SIZE = (4032, 3024)


def make_phone_jpeg(width: int, height: int) -> bytes:
"""Encode a JPEG shaped like a phone upload: camera EXIF, GPS, a serial number and a rotation.

A smooth gradient compresses to a fraction of a real photo and decodes much faster, so
seeded noise is blended in to bring the file to a photo's size (about 4 MB at 12 MP).
"""
base = PILImage.effect_mandelbrot((width // 8, height // 8), (-2.0, -1.2, 1.0, 1.2), 100).resize(
(width, height), PILImage.Resampling.BICUBIC
)
noise = PILImage.frombytes("L", (width, height), random.Random(0).randbytes(width * height)) # noqa: S311 -- seeded on purpose, not a secret
img = PILImage.merge(
"RGB", (base, PILImage.blend(base, noise, 0.2), PILImage.linear_gradient("L").resize((width, height)))
)
exif = img.getexif()
exif[0x010F] = "Test Camera" # Make (kept)
exif[0x0110] = "Model X" # Model (kept)
exif[0x0131] = "Firmware 1.0" # Software (dropped)
exif[0x0112] = 6 # Orientation: rotate 90 degrees, so the rotation is baked in
sub_ifd = exif.get_ifd(IFD.Exif)
sub_ifd[0x920A] = 4.2 # FocalLength (kept)
sub_ifd[0x8827] = 400 # ISOSpeedRatings (kept)
sub_ifd[0xA431] = "SERIAL123" # BodySerialNumber (dropped)
gps = exif.get_ifd(IFD.GPSInfo)
gps[1] = "N" # GPSLatitudeRef (dropped with the whole GPS block)
gps[2] = (52.0, 9.0, 36.0) # GPSLatitude
buffer = io.BytesIO()
img.save(buffer, format="JPEG", exif=exif, quality=90)
return buffer.getvalue()


@pytest.fixture(scope="session")
def phone_jpeg() -> bytes:
"""A 12 MP JPEG with identifying EXIF and a 90 degree rotation tag."""
return make_phone_jpeg(*PHOTO_SIZE)


def _component_create(depth: int, breadth: int, prefix: str) -> dict[str, Any]:
node: dict[str, Any] = {
"name": f"Component {prefix}",
"description": "Sub-assembly removed with basic hand tools.",
"brand": "Brand",
"model": f"Model {prefix}",
"amount_in_parent": 2,
"weight_g": 120.5,
"height_cm": 10,
"width_cm": 5,
"depth_cm": 3,
"bill_of_materials": [
{"quantity": 0.3, "unit": "g", "material_id": 1},
{"quantity": 0.1, "unit": "g", "material_id": 2},
],
"components": [],
}
if depth > 0:
node["components"] = [_component_create(depth - 1, breadth, f"{prefix}.{i}") for i in range(breadth)]
return node


@pytest.fixture(scope="session")
def product_create_payload() -> dict[str, Any]:
"""A product create request body with 84 components over 3 levels (4 x 4 x 4 + 4 x 4 + 4)."""
return {
"name": "Office Chair",
"description": "Complete chair assembly",
"brand": "Brand 1",
"model": "Model 1",
"product_type_id": 1,
"weight_g": 20000,
"height_cm": 150,
"width_cm": 70,
"depth_cm": 50,
"circularity_properties": {
"recyclability": "Metal frame and plastic shell can be separated with basic tools.",
"disassemblability": "Most fasteners are visible and non-destructive to remove.",
},
"videos": [{"url": "https://www.youtube.com/watch?v=123456789", "description": "Disassembly video"}],
"bill_of_materials": [{"quantity": 0.3, "unit": "g", "material_id": 1}],
"components": [_component_create(2, 4, str(i)) for i in range(4)],
}


@dataclass(frozen=True, slots=True)
class ProductTree:
"""A base product and its components as unsaved ORM rows, shaped like the tree loaders' output."""

root: Product
children_by_parent_id: dict[int, list[Product]]

@property
def size(self) -> int:
"""Number of components below the base product, every level counted."""
return sum(len(children) for children in self.children_by_parent_id.values())


def _owner(index: int, visibility: str) -> User:
return User(
id=uuid.UUID(int=index, version=4),
email=f"owner{index}@example.org",
username=f"owner{index}",
hashed_password="not-a-hash",
preferences={"profile_visibility": visibility},
)


@pytest.fixture(scope="session")
def product_tree() -> ProductTree:
"""A base product with as many components as one tree read returns (1,000), eight levels deep.

Four top-level components, each with two children, filled breadth first until the cap.
Rows alternate between a public and a private owner, so owner redaction runs on half of
them. They carry no images: image URLs are derived from a stat of the stored file, which
is I/O and outside what an instruction count can say anything about.
"""
owners = (_owner(1, "public"), _owner(2, "private"))
product_type = ProductType(id=1, name="Office chair", description="Swivel chair with armrests")
materials = (
Material(id=1, name="Steel", density_kg_m3=7850.0, is_crm=False),
Material(id=2, name="Polypropylene", density_kg_m3=905.0, is_crm=False),
)
next_id = iter(range(1, COMPONENT_TREE_MAX_COMPONENTS + 2))

def row(parent_id: int | None) -> Product:
product_id = next(next_id)
owner = owners[product_id % 2]
return Product(
id=product_id,
name=f"=Part {product_id}" if product_id % 7 == 0 else f"Part {product_id}",
description='Removed with a T10 screwdriver; held by "glue" at two points.',
brand="brand",
model=f"Model {product_id}",
product_type_id=product_type.id,
product_type=product_type,
weight_g=120.5,
height_cm=10.0,
width_cm=5.0,
depth_cm=3.0,
circularity_properties={"recyclability": "Separable", "remanufacturability": "Standard screws"},
owner_id=owner.id,
owner=owner,
parent_id=parent_id,
amount_in_parent=None if parent_id is None else 2,
version=1,
created_at=_TIMESTAMP,
updated_at=_TIMESTAMP,
bill_of_materials=[
MaterialProductLink(
material_id=material.id,
material=material,
product_id=product_id,
quantity=0.25,
unit=Unit.KILOGRAM,
created_at=_TIMESTAMP,
updated_at=_TIMESTAMP,
)
for material in materials
],
)

root = row(None)
children_by_parent_id: dict[int, list[Product]] = {}
queue = deque([root])
made = 0
while queue and made < COMPONENT_TREE_MAX_COMPONENTS:
parent = queue.popleft()
for _ in range(4 if parent is root else 2):
if made == COMPONENT_TREE_MAX_COMPONENTS:
break
child = row(parent.id)
children_by_parent_id.setdefault(parent.id, []).append(child)
queue.append(child)
made += 1
return ProductTree(root=root, children_by_parent_id=children_by_parent_id)
42 changes: 42 additions & 0 deletions backend/tests/benchmarks/test_bench_images.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""Benchmarks for the image upload pipeline, which runs on every image upload before the response."""

import io
from typing import TYPE_CHECKING

import pytest
from PIL import Image as PILImage

from app.core.images import THUMBNAIL_WIDTHS, generate_thumbnails, process_image_for_storage
from tests.benchmarks.conftest import PHOTO_SIZE

if TYPE_CHECKING:
from pathlib import Path

from pytest_codspeed import BenchmarkFixture


@pytest.mark.benchmark
def test_process_image_for_storage(benchmark: BenchmarkFixture, phone_jpeg: bytes) -> None:
"""Strip identifying metadata from a phone photo and bake in its rotation."""

# The upload is rewritten in place, so each round gets a fresh copy, made outside the measurement.
def fresh_upload() -> tuple[tuple[io.BytesIO], dict[str, object]]:
return (io.BytesIO(phone_jpeg),), {}

width, height = benchmark.pedantic(process_image_for_storage, setup=fresh_upload)
assert (height, width) == PHOTO_SIZE


@pytest.mark.benchmark
def test_generate_thumbnails(benchmark: BenchmarkFixture, phone_jpeg: bytes, tmp_path: Path) -> None:
"""Write every thumbnail width for a stored phone photo."""
image_path = tmp_path / "original.jpg"
image_path.write_bytes(phone_jpeg)

paths = benchmark(generate_thumbnails, image_path)

widths = []
for path in paths:
with PILImage.open(path) as thumbnail:
widths.append(thumbnail.width)
assert sorted(widths) == sorted(THUMBNAIL_WIDTHS)
Loading
Loading