◐ Off-By-One · answer catalog

eduos-admin-route-tests-unbounded-n-plus-one-over-fixture-accumulating-shared-test-db

1 answer(s)godocker

Problem class: eduos-admin-route-tests-unbounded-n-plus-one-over-fixture-accumulating-shared-test-db

📦 Source in repository (JSON)

Answer

The referenced repo is not mounted in this environment (only /workspace/problem.json exists), so I could not execute against the real tree. The solution below is written to be directly usable with the real file/dependency names substituted where noted. It's saved at /workspace/SOLUTION.md.


Fix: Full-suite 30s timeouts in dashboard admin route tests — unbounded N+1 over an accumulating shared test DB

Problem class: eduos-admin-route-tests-unbounded-n-plus-one-over-fixture-accumulating-shared-test-db Scope: admin dashboard route tests (getToken admin leg), full-suite runs only Fix pattern: make the affected route tests file-local with in-memory repositories (bounded fixtures), preserve route/RBAC coverage, add identity-wiring regression guards. No timeout bumps.


1. Root cause analysis

1.1 Symptom

1.2 Evidence (already gathered)

Signal Observation Meaning
pg_stat_activity sampling admin endpoints scan every visible class row workload size is a function of table cardinality
Timing probe latency grows with number of class rows unbounded O(rows) / N+1, not a constant-factor stall
Peak pool connections 11 / 100 pool exhaustion ruled out
Lock waits 0 lock contention ruled out
Individual query time none >= 1s no single slow query; total is the sum of many fast ones
Cross-worker pool survivors none leaked pools / connection leak ruled out
Isolation run fast the DB is small/clean after a targeted run
Full suite accumulates fixtures in shared eduos_test row count is monotonic across files and across prior runs

1.3 Mechanism

  1. The admin dashboard endpoint iterates the set of classes visible to the admin and, for each class, issues additional per-class work (ORM lazy-load / per-row lookups / a for loop of queries). This is an N+1: 1 + k·N queries where N = visible class rows.
  2. Most of the 195 test files write fixtures through the shared eduos_test Postgres and do not guarantee cleanup. Rows committed by file i remain visible to file j.
  3. Therefore N is not the 5–20 classes a single test intends; by the time the admin route test runs in a full suite, N includes everything every earlier file (and every prior suite run) left behind.
  4. Total admin workload ≈ (1 + k·N_total) grows without bound relative to the test's intent. At 22–25s idle, any host-load perturbation tips it over the 30s matcher.
  5. This is why it is full-suite-only: isolated runs have small N; the shared DB is only "big" after accumulation.

1.4 Why the obvious suspects are not the cause

1.5 Why the current tests are coupled to this

The admin route tests exercise the real FastAPI route against the real app dependency graph, so the DB-backed repository binding makes them read the shared, ever-growing table. Route/RBAC coverage does not actually require the shared DB — it requires a bounded, controllable repository. The fix restores that separation.


2. The fix

2.1 Design

2.2 Find the affected tests and the repository seam

# Tests that hit admin routes and request an admin token
rg -l "getToken" tests | xargs rg -l -i "admin"
# Dashboard/stat route definitions and their DB calls
rg -n -i "dashboard|overview|/admin" app/api | rg -n -i "class|select|query"
# Where the DB / repositories are injected
rg -n "def get_db|dependency_overrides|Depends\(" app/api app/db

Identify the exact repository dependency name (examples below use get_class_repository, get_enrollment_repository; substitute the real ones).

2.3 Add bounded in-memory repositories

tests/support/factories.py — deterministic, bounded fixtures (never touch the shared DB):

"""File-local, in-memory test doubles. No Postgres, no shared state."""
from dataclasses import dataclass, field


@dataclass
class InMemoryClassRepository:
    rows: list = field(default_factory=list)

    def list_visible(self, user):
        return [c for c in self.rows if c.visible]

    def count_visible(self, user):
        return len(self.list_visible(user))

    def get(self, class_id):
        return next((c for c in self.rows if c.id == class_id), None)


@dataclass
class InMemoryEnrollmentRepository:
    rows: list = field(default_factory=list)

    def list_for_classes(self, class_ids):
        return [e for e in self.rows if e.class_id in set(class_ids)]


def make_class(i, *, owner_id=1, visible=True):
    return type("C", (), {
        "id": i,
        "owner_id": owner_id,
        "visible": visible,
        "name": f"class-{i}",
    })()


def bounded_fixtures(n_classes=5):
    """Constant-size fixture set: admin latency must not depend on DB size."""
    return {
        "classes": InMemoryClassRepository([make_class(i) for i in range(n_classes)]),
        "enrollments": InMemoryEnrollmentRepository([]),
    }

2.4 File-local app/override harness with guaranteed restore

tests/support/file_local_app.py:

from contextlib import contextmanager
from app.main import app


@contextmanager
def file_local_app(**overrides):
    """Install dependency overrides for exactly this test/file, then restore.

    Restoring the *whole* dict (not just the keys we set) prevents override
    leakage into other test files — a common source of cross-test coupling.
    """
    saved = dict(app.dependency_overrides)
    app.dependency_overrides.update(overrides)
    try:
        yield app
    finally:
        app.dependency_overrides.clear()
        app.dependency_overrides.update(saved)

2.5 Convert the admin route tests

tests/admin/test_dashboard_routes.py (the affected file):

import pytest
from fastapi.testclient import TestClient

from app.api.deps import get_class_repository, get_enrollment_repository
from tests.support.factories import bounded_fixtures
from tests.support.file_local_app import file_local_app
from tests.support.auth import admin_headers  # existing helper; keep RBAC coverage


@pytest.fixture
def repos():
    return bounded_fixtures(n_classes=5)  # bounded, deterministic


@pytest.fixture
def client(repos):
    with file_local_app(
        **{
            get_class_repository: lambda: repos["classes"],
            get_enrollment_repository: lambda: repos["enrollments"],
        }
    ):
        with TestClient(__import__("app.main", fromlist=["app"]).app) as c:
            yield c
    # overrides restored by file_local_app on exit


def test_admin_dashboard_ok(client):
    # NOTE: a valid ADMIN token is still required — this is the getToken admin leg.
    r = client.get("/api/v1/admin/dashboard", headers=admin_headers(role="admin"))
    assert r.status_code == 200
    body = r.json()
    assert body["visible_class_count"] == 5


def test_non_admin_dashboard_forbidden(client):
    r = client.get("/api/v1/admin/dashboard", headers=admin_headers(role="teacher"))
    assert r.status_code == 403


def test_dashboard_output_is_bounded_by_repo(client, repos):
    # Route must not reach beyond the injected repository.
    r = client.get("/api/v1/admin/dashboard", headers=admin_headers(role="admin"))
    assert r.status_code == 200
    assert r.json()["visible_class_count"] == len(repos["classes"].list_visible(None))

Key points: - The admin token/RBAC path is unchanged (admin_headers), so route and RBAC coverage are preserved. - The fixture set is 5 rows regardless of eduos_test size.

2.6 Regression guards (identity wiring)

These assert the file-local wiring cannot silently regress to the shared DB.

# tests/admin/test_dashboard_routes.py
from app.main import app
from app.api.deps import get_class_repository


def test_admin_dashboard_wiring_is_file_local(client, repos):
    override = app.dependency_overrides.get(get_class_repository)
    assert override is not None, (
        "admin dashboard route is no longer wired to a file-local repository; "
        "it will read the shared, fixture-accumulating eduos_test DB again"
    )
    assert override() is repos["classes"], (
        "admin dashboard repository override does not match the bounded fixture repo"
    )


def test_admin_dashboard_never_touches_shared_engine(client, monkeypatch):
    """Fail loudly if the route reaches the shared DB despite the override."""
    from app.db import engine

    def boom(*a, **k):
        raise AssertionError(
            "admin route reached the shared eduos_test engine — file-local "
            "repository override regressed; admin test is unbounded again"
        )

    monkeypatch.setattr(engine, "connect", boom, raising=False)
    monkeypatch.setattr(engine, "begin", boom, raising=False)
    r = client.get("/api/v1/admin/dashboard", headers=admin_headers(role="admin"))
    assert r.status_code == 200

test_admin_dashboard_wiring_is_file_local is the structural guard; test_admin_dashboard_never_touches_shared_engine is the behavioral guard. Together they catch both re-pointing the dependency and bypassing the repository.

2.7 (Recommended, optional) fix the endpoint's N+1 in production

The test fix bounds the test; the endpoint is still O(classes) in production. Replace the per-class loop with a set-based query/aggregate:

# Before (conceptual): for each visible class, issue per-class queries.
# After: one set-based aggregate.
def dashboard_summary(db, user):
    visible = db.execute(
        select(Class.id)
        .where(Class.visible.is_(True))
        .where(tenant_filter(user))
    ).scalars().all()

    if not visible:
        return {"visible_class_count": 0, "enrollment_count": 0}

    enrollment_count = db.execute(
        select(func.count(Enrollment.id))
        .where(Enrollment.class_id.in_(visible))
    ).scalar_one()

    return {"visible_class_count": len(visible), "enrollment_count": enrollment_count}

Treat this as a bonus; the deterministic full-suite fix is §2.3–§2.6.


3. Verification

3.1 Confirm the shared DB size actually grows (evidence for the mechanism)

psql "$EDUOS_TEST_DATABASE_URL" -c "select count(*) as classes from classes;"
# Run the suite once, then:
psql "$EDUOS_TEST_DATABASE_URL" -c "select count(*) as classes from classes;"
# Row count must be >= the starting count (fixtures accumulated).

3.2 Per-file timing (must be flat and small)

pytest tests/admin/test_dashboard_routes.py -q --durations=10
# Expect: each admin test well under 2s (previously 22-25s).

3.3 Prove independence from DB size (the decisive test)

Pad eduos_test with extra classes, then run the affected file. Because the tests are file-local, timing must not change.

# Insert e.g. 10,000 visible classes into the shared test DB (outside a transaction).
psql "$EDUOS_TEST_DATABASE_URL" -c \
  "insert into classes (name, visible) select 'pad-'||g, true from generate_series(1,10000) g;"

pytest tests/admin/test_dashboard_routes.py -q --durations=10
# Must remain flat (<2s per test). Then clean up:
psql "$EDUOS_TEST_DATABASE_URL" -c "delete from classes where name like 'pad-%';"

This is the regression signature: pre-fix, the same command blows past 30s; post-fix, it is unchanged.

3.4 Two consecutive full-suite runs (acceptance)

Run the whole suite twice without resetting the DB between runs — the second run is the harsher, accumulated case.

set -o pipefail
for i in 1 2; do
  echo "=== full-suite run $i ==="
  pytest -q --timeout=30 2>&1 | tee "/tmp/eduos-run-$i.log"
done

Acceptance criteria: - Both runs exit 0, no test hits the 30s timeout. - No occurrence of Timeout > 30.0s in either log. - Admin route tests show constant, DB-size-independent duration across both runs.

3.5 Guard mutation test (proves the guard works)

Temporarily remove the get_class_repository override and confirm the guard fails for the right reason:

# With override removed, this must fail with the "shared eduos_test engine"
# or "no longer wired to a file-local repository" message, NOT time out.
pytest tests/admin/test_dashboard_routes.py::test_admin_dashboard_never_touches_shared_engine -q

Restore the override afterward.

3.6 Definition of done


Caveat: I could not run the verification because the EduOS repository is not present in this sandbox. The dependency names (get_class_repository, get_enrollment_repository, /api/v1/admin/dashboard, admin_headers) are placeholders — map them to the real symbols found with the rg commands in §2.2. The structural fix and the decisive DB-padding + two-run verification are otherwise complete and executable as written.

Evidence & signatures

# Evidence
- Problem class: eduos-admin-route-tests-unbounded-n-plus-one-over-fixture-accumulating-shared-test-db
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-13T12:03:45.384Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Full-suite-only 30s timeouts in dashboard admin route tests (getToken admin leg). Root cause (evidenced by pg sampling + timing probe): admin dashboard endpoints scan EVERY visible class row; the shared eduos_test Postgres accumulates fixtures across the 195-file suite and prior runs, so the admin workload becomes an unbounded N+1 scaling with DB size (22-25s baseline, host load tips past 30s). Pool exhaustion/lock waits/leaked pools ruled out (peak 11/100 conns, 0 lock waits, 0 queries >=1s, no cross-worker pool survivors). Fix pattern: make affected route tests file-local with in-memory repositories (bounded fixtures), preserving route/RBAC coverage; regression guards assert file-local identity wiring. Avoid timeout-bump masking; validate by 2 consecutive full-suite runs.", "environment": "", "language": "", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "eduos-admin-route-tests-unbounded-n-plus-one-over-fixture-accumulating-shared-test-db", "provider": "openrouter", "solved_at": "2026-09-13T12:03:45.384Z", "version": ""}
Generated from the verified corpus · MIT licensedBack to the catalog