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
29 changes: 27 additions & 2 deletions .github/workflows/python-application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,19 @@ on:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
workflow_dispatch:
inputs:
run_performance:
description: "Run performance benchmark tests"
required: false
default: false
type: boolean

permissions:
contents: read

jobs:
build:
lint-and-fast-tests:
runs-on: ubuntu-latest

steps:
Expand All @@ -32,6 +39,24 @@ jobs:
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
# Exit-zero treats all errors as warnings.
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Test with pytest
- name: Test with pytest (fast suite)
run: |
pytest

performance-tests:
runs-on: ubuntu-latest
if: ${{ github.event_name == 'workflow_dispatch' && inputs.run_performance == true }}

steps:
- uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
- name: Test with pytest (performance suite)
run: |
pytest -m performance
16 changes: 12 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Guide for AI coding agents working on the Commerce System Demo project.

Commerce System Demo is a FastAPI-based commerce service that provides RESTful APIs for managing products, categories, and implementing search functionality. The project includes built-in observability with OpenTelemetry metrics, logging, and distributed tracing.

**Current Version**: 0.1.3 (following [Semantic Versioning](https://semver.org/))
**Current Version**: 0.1.4 (following [Semantic Versioning](https://semver.org/))

## Setup Commands

Expand Down Expand Up @@ -79,7 +79,8 @@ app/

### Run Tests

- **All tests**: `pytest` or `pytest tests/`
- **Default fast suite**: `pytest` or `pytest tests/` (performance benchmarks are excluded by default)
- **Performance benchmarks (opt-in)**: `pytest -m performance`
- **Specific test file**: `pytest tests/test_api.py` or `pytest tests/test_search.py`
- **Specific test**: `pytest tests/test_api.py::test_create_category -v`
- **With coverage**: `pytest --cov=app --cov-report=html`
Expand All @@ -90,18 +91,25 @@ app/
- **tests/conftest.py**: Shared fixtures (db_session, client setup)
- **tests/test_api.py**: Integration tests for CRUD operations and search endpoints
- **tests/test_search.py**: Service-level search functionality tests
- **tests/test_category_service_benchmark.py**: Performance benchmark tests for category validation (marked with `@pytest.mark.performance`)

### Database for Tests

- Tests use `testcontainers` to spin up real PostgreSQL instances
- Each test session gets a fresh database to avoid state leakage
- Tests run in async mode (`asyncio_mode = "auto"`)

### CI Test Execution

- **Push/PR CI** runs the default fast suite and excludes `performance`-marked tests
- **Performance CI** is opt-in via GitHub Actions `workflow_dispatch`
- To run benchmark CI, start workflow **Python application** with input `run_performance=true`

## Building and Deployment

### Docker

- **Build image**: `docker build -t commerce-system-demo:0.1.3 .`
- **Build image**: `docker build -t commerce-system-demo:0.1.4 .`
- **View Dockerfile**: Includes Python dependencies, migration scripts, and app code
- **Build context**: Includes `scripts/`, `app/`, and `observability/` directories

Expand Down Expand Up @@ -184,7 +192,7 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/):
- **MINOR**: Backward-compatible new features
- **PATCH**: Backward-compatible bug fixes

Current version is **0.1.3** (initial development). Version is defined in:
Current version is **0.1.4** (initial development). Version is defined in:
- `pyproject.toml` (project metadata)
- `app/main.py` (FastAPI version)
- `app/observability/metrics.py` (meter version)
Expand Down
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Advanced filtering options for product search
- Product images storage optimization

## [0.1.4] - 2026-03-22

### Changed

- Replaced O(n) per-level ancestor walks in `category_depth` and `validate_no_cycles` with single recursive CTE queries
- Category depth validation now executes one SQL statement instead of up to 100 sequential fetches
- Cycle detection now uses a single `EXISTS` query over a recursive ancestor CTE

### Added

- Integration benchmark test for category validation on PostgreSQL testcontainers (`test_category_service_benchmark.py`)
- Opt-in `performance` pytest marker to separate benchmarks from the default fast test suite
- Dedicated `performance-tests` CI job triggered via `workflow_dispatch` with `run_performance=true`
- Documentation in README and AGENTS.md for running performance benchmarks locally and in CI

## [0.1.3] - 2026-03-20

### Added
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -739,6 +739,16 @@ pytest tests/test_search.py -q # Service layer tests
pytest tests/test_api.py -q # Endpoint integration tests
```

Run opt-in performance benchmarks:

```bash
pytest -m performance -q
```

The default test run excludes performance benchmarks to keep CI fast.
To run benchmarks in GitHub Actions, start the `Python application` workflow
manually and set `run_performance` to `true`.

Run with coverage:

```bash
Expand Down
2 changes: 1 addition & 1 deletion app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ def create_app() -> FastAPI:

app = FastAPI(
title="Commerce System Demo",
version="0.1.3",
version="0.1.4",
lifespan=lifespan,
)
app.router.route_class = ObservabilityRoute
Expand Down
2 changes: 1 addition & 1 deletion app/observability/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from opentelemetry import metrics
from opentelemetry.metrics import Counter, Histogram, UpDownCounter

_meter = metrics.get_meter("commerce-system-demo-observability", version="0.1.3")
_meter = metrics.get_meter("commerce-system-demo-observability", version="0.1.4")

http_request_duration_seconds: Histogram = _meter.create_histogram(
name="commerce_http_request_duration_seconds",
Expand Down
2 changes: 1 addition & 1 deletion app/observability/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ def _build_resource(settings: Settings) -> Resource:
"""Build OpenTelemetry resource attributes from runtime settings."""
attributes = {
"service.name": settings.otel_service_name,
"service.version": "0.1.3",
"service.version": "0.1.4",
"deployment.environment": settings.otel_environment,
}

Expand Down
69 changes: 48 additions & 21 deletions app/services/category_service.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
"""Category business logic helpers and hierarchy utilities."""

from sqlalchemy import Select, select
from sqlalchemy import Select, exists, func, literal, select
from sqlalchemy.ext.asyncio import AsyncSession

from app.models.category import Category
from app.observability.db_timing import timed_get
from app.observability.db_timing import timed_execute_scalar_one, timed_get

MAX_CATEGORY_DEPTH = 100

Expand All @@ -26,31 +26,58 @@ async def get_category_or_none(session: AsyncSession, category_id: int) -> Categ
return await timed_get(session, Category, category_id)


def _ancestor_chain_cte(start_category_id: int, depth_limit: int | None = None):
"""Build a recursive CTE that walks from a category to the root by parent links."""
ancestor_chain: Select = (
select(
Category.id.label("id"),
Category.parent_id.label("parent_id"),
literal(1).label("depth"),
)
.where(Category.id == start_category_id)
.cte(name="ancestor_chain", recursive=True)
)

category_alias = Category.__table__.alias("category_alias")
recursive_step = select(
category_alias.c.id,
category_alias.c.parent_id,
(ancestor_chain.c.depth + 1).label("depth"),
).where(category_alias.c.id == ancestor_chain.c.parent_id)

if depth_limit is not None:
recursive_step = recursive_step.where(ancestor_chain.c.depth < depth_limit)

return ancestor_chain.union_all(recursive_step)


async def category_depth(session: AsyncSession, parent_id: int | None) -> int:
"""Compute ancestor depth for a parent candidate in the category tree."""
depth = 0
current_parent_id = parent_id
while current_parent_id is not None:
depth += 1
if depth > MAX_CATEGORY_DEPTH:
return depth
parent = await timed_get(session, Category, current_parent_id)
if parent is None:
break
current_parent_id = parent.parent_id
return depth
if parent_id is None:
return 0

ancestor_chain = _ancestor_chain_cte(parent_id, depth_limit=MAX_CATEGORY_DEPTH + 1)
depth_statement = select(func.coalesce(func.max(ancestor_chain.c.depth), 0))
depth = await timed_execute_scalar_one(session, depth_statement)
return int(depth)


async def validate_no_cycles(session: AsyncSession, category_id: int, new_parent_id: int | None) -> None:
"""Ensure re-parenting a category does not create a cycle."""
cursor = new_parent_id
while cursor is not None:
if cursor == category_id:
raise ValueError("Category cycle detected")
candidate = await timed_get(session, Category, cursor)
if candidate is None:
break
cursor = candidate.parent_id
if new_parent_id is None:
return

ancestor_chain = _ancestor_chain_cte(new_parent_id, depth_limit=MAX_CATEGORY_DEPTH + 1)
cycle_check_statement = select(
exists(
select(1)
.select_from(ancestor_chain)
.where(ancestor_chain.c.id == category_id)
)
)
cycle_detected = await timed_execute_scalar_one(session, cycle_check_statement)
if bool(cycle_detected):
raise ValueError("Category cycle detected")


def category_subtree_cte(root_category_id: int):
Expand Down
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "commerce-system-demo"
version = "0.1.3"
version = "0.1.4"
description = "FastAPI commerce service demo"
readme = "README.md"
requires-python = ">=3.11"
Expand Down Expand Up @@ -39,6 +39,10 @@ asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "session"
asyncio_default_test_loop_scope = "session"
testpaths = ["tests"]
addopts = "-m 'not performance'"
markers = [
"performance: benchmarks and perf-sensitive integration tests (opt-in)",
]

[tool.setuptools]
include-package-data = true
Expand Down
47 changes: 26 additions & 21 deletions tests/test_category_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,32 +11,37 @@
@pytest.mark.asyncio
async def test_category_depth_stops_when_parent_missing(monkeypatch: pytest.MonkeyPatch):
session = AsyncMock()

async def fake_timed_get(_session, _model, category_id):
if category_id == 1:
return SimpleNamespace(parent_id=2)
return None

monkeypatch.setattr(category_service, "timed_get", fake_timed_get)
timed_execute_scalar_one = AsyncMock(return_value=1)
monkeypatch.setattr(category_service, "timed_execute_scalar_one", timed_execute_scalar_one)

depth = await category_service.category_depth(session, parent_id=1)
assert depth == 2
assert depth == 1
timed_execute_scalar_one.assert_awaited_once()


@pytest.mark.asyncio
async def test_validate_no_cycles_raises_for_cycle(monkeypatch: pytest.MonkeyPatch):
async def test_category_depth_returns_zero_for_none_parent(monkeypatch: pytest.MonkeyPatch):
session = AsyncMock()
timed_execute_scalar_one = AsyncMock()
monkeypatch.setattr(category_service, "timed_execute_scalar_one", timed_execute_scalar_one)

depth = await category_service.category_depth(session, parent_id=None)

async def fake_timed_get(_session, _model, category_id):
if category_id == 2:
return SimpleNamespace(parent_id=1)
return None
assert depth == 0
timed_execute_scalar_one.assert_not_awaited()

monkeypatch.setattr(category_service, "timed_get", fake_timed_get)

@pytest.mark.asyncio
async def test_validate_no_cycles_raises_for_cycle(monkeypatch: pytest.MonkeyPatch):
session = AsyncMock()
timed_execute_scalar_one = AsyncMock(return_value=1)
monkeypatch.setattr(category_service, "timed_execute_scalar_one", timed_execute_scalar_one)

with pytest.raises(ValueError, match="Category cycle detected"):
await category_service.validate_no_cycles(session, category_id=1, new_parent_id=2)

timed_execute_scalar_one.assert_awaited_once()


@pytest.mark.asyncio
async def test_validate_category_parent_raises_not_found(monkeypatch: pytest.MonkeyPatch):
Expand Down Expand Up @@ -120,11 +125,11 @@ async def test_validate_category_reparent_raises_depth_error(monkeypatch: pytest
@pytest.mark.asyncio
async def test_category_depth_returns_when_exceeding_max(monkeypatch: pytest.MonkeyPatch):
session = AsyncMock()

async def fake_timed_get(_session, _model, category_id):
return SimpleNamespace(parent_id=category_id + 1)

monkeypatch.setattr(category_service, "timed_get", fake_timed_get)
monkeypatch.setattr(
category_service,
"timed_execute_scalar_one",
AsyncMock(return_value=category_service.MAX_CATEGORY_DEPTH + 1),
)

depth = await category_service.category_depth(session, parent_id=1)
assert depth == category_service.MAX_CATEGORY_DEPTH + 1
Expand All @@ -133,6 +138,6 @@ async def fake_timed_get(_session, _model, category_id):
@pytest.mark.asyncio
async def test_validate_no_cycles_breaks_on_missing_candidate(monkeypatch: pytest.MonkeyPatch):
session = AsyncMock()
monkeypatch.setattr(category_service, "timed_get", AsyncMock(return_value=None))
monkeypatch.setattr(category_service, "timed_execute_scalar_one", AsyncMock(return_value=0))

await category_service.validate_no_cycles(session, category_id=1, new_parent_id=2)
await category_service.validate_no_cycles(session, category_id=1, new_parent_id=2)
Loading
Loading