# Performance & Capacity

> **Status:** v0.1 — first pass, finalized 2026-05-15
> (review-pass verified all file:line citations against the
> live source). Last updated 2026-05-15.
> A federation-sibling-scoped performance audit. The library
> is small (3 public names); SQLAlchemy owns the heavy
> performance work. PC items here are the **per-request
> tenant-session overhead** + **engine/session construction
> cost** + **pool_pre_ping overhead**.
>
> **Scope:** mgf-sqlalchemy's OWN performance footprint. NOT
> a re-audit of SQLAlchemy / asyncpg / psycopg.
>
> **Audience:** the maintainer + future AI agents.
> **Cross-references:**
> [`RELIABILITY_AND_ACCURACY.md`](RELIABILITY_AND_ACCURACY.md),
> [`SECURITY_HARDENING.md`](SECURITY_HARDENING.md),
> [`mgf-common/docs/inprogress/PERFORMANCE_AND_CAPACITY.md`](https://codeberg.org/magogi-admin/mgf_common/src/branch/main/docs/inprogress/PERFORMANCE_AND_CAPACITY.md)
> (cornerstone).

For multi-tenant consumers, `tenant_session` runs once per
request. Its overhead is the load-bearing PC concern. Other
items: engine construction (one-shot per process; matters
for test fixtures), `pool_pre_ping` cost per checkout,
UUID parsing.

---

## §1 You are here

mgf-sqlalchemy ships three public names. `tenant_session`
runs at the start of every tenant-scoped request: UUID
parse + 1 `SET LOCAL` SQL execution. `create_engine` runs
once per process; `create_sessionmaker` runs once per
process; both are amortised.

The performance question reduces to: **how cheap is
`tenant_session`?** A 100-request/sec multi-tenant FastAPI
worker invokes `tenant_session` 100 times/sec. Each invocation:

1. UUID validation (~1-5 µs).
2. SQL `SET LOCAL` (network roundtrip to Postgres if not
   pipelined — 100s of µs to ms).

The Python overhead is small; the SQL roundtrip dominates.

**Counts (2026-05-17):**

| Priority | Open | Closed | Deferred to v1.1+ | Total |
|---|---|---|---|---|
| P0 — pre-v1.0 blockers | 0 | 0 | 0 | 0 |
| P1 — should land before/with v1.0 | 0 | 2 | 0 | 2 |
| P2 — post-v1.0 hardening | 0 | 2 | 1 | 3 |
| **Total** | **0** | **4** | **1** | **5** |

**Do first:** all v1.0-relevant items closed. PC-05 deferred
to v1.1+ — it measures the cost of the SH-04 helper which is
itself deferred; the perf test ships alongside the helper.

---

## §2 Priority table

| ID | Theme | One-liner | Effort | Status |
|---|---|---|---|---|
| [**PC-01**](#pc-01-tenant-session-python-overhead-pin) | Latency | `tenant_session` does UUID parse + SQL execute. The Python overhead (excluding SQL) is ~5-10 µs. Pin a budget. The SQL latency (~100 µs-ms depending on driver) is uncontrolled by us. | S | ✅ closed |
| [**PC-02**](#pc-02-pool-pre-ping-overhead) | Latency (per-checkout) | `pool_pre_ping=True` (default) sends a `SELECT 1` on every connection checkout. Saves operators from stale-connection bugs; costs ~one network roundtrip per checkout. For a 100 req/s app, that's 100 RTT/sec of extra DB work. Quantify + document the tradeoff. | XS | ✅ closed |
| [**PC-03**](#pc-03-engine-construction-cost) | Startup | `create_engine(url)` constructs an `AsyncEngine` without connecting. For test fixtures that build a fresh engine per test, the cost compounds. | XS | ✅ closed |
| [**PC-04**](#pc-04-sessionmaker-construction-cost) | Startup | `create_sessionmaker(engine)` returns an `async_sessionmaker` factory. Cost is minimal (frozen state) but worth pinning. | XS | ✅ closed |
| [**PC-05**](#pc-05-rls-policy-presence-audit-cost) | Latency | The proposed `assert_rls_policies_present` helper (SH-04) queries `pg_policy` for each tenant-scoped table. For an app with 50 tables, 50 catalog queries at startup. Document + pin the budget. | S | ⏳ v1.1+ — depends on SH-04 helper which is itself deferred to v1.1+; the perf test ships alongside the helper. |

Effort scale: XS (≤ 15 min); S (≤ 1 h); M (≤ 1 session).

---

## §3 Invariants we hold

The list of "closed elsewhere; do not re-open":

- **PC-INV-1** — `tenant_session` issues **one SQL
  statement** (`SET LOCAL app.current_tenant = '<uuid>'`)
  per call. No additional roundtrips, no per-call
  validation queries.
- **PC-INV-2** — UUID validation uses `uuid.UUID(...)` —
  CPython C-extension; sub-µs per call.
- **PC-INV-3** — `create_engine` is **lazy**; doesn't open
  a connection at construction. First-connect happens at
  first use.
- **PC-INV-4** — Pool defaults are **production-leaning**:
  `pool_size=5`, `max_overflow=10`, `pool_recycle=3600`,
  `pool_pre_ping=True`. Documented in
  [`_engine.py:25-28`](../../src/mgf/sqlalchemy/_engine.py#L25).
- **PC-INV-5** — SQLite path uses **StaticPool / NullPool**
  per SQLAlchemy's defaults — pool kwargs are stripped via
  the `is_sqlite` check. No SQLite-side pool overhead.

---

## §4 Work units

### PC-01 tenant_session Python overhead pin

**Why:** `tenant_session`
([`_session.py:56-99`](../../src/mgf/sqlalchemy/_session.py#L56))
runs:

1. UUID parse (`UUID(tenant_id)`) — ~0.5-2 µs.
2. `str(validated)` — ~0.5 µs.
3. f-string format — ~0.5 µs.
4. `await session.execute(text(...))` — SQL roundtrip
   (NOT measured here; consumer + DB).
5. `yield session` — context-manager yield.

The Python-side overhead is the wrapper's contribution.
The SQL is uncontrolled.

For a 100 req/s app, the Python overhead × 100 = 1 ms/sec
of CPU just in our wrapper.

**Concrete output:**

- Add `tests/perf/test_tenant_session_overhead.py`:
  - Mock `session.execute` as a no-op coroutine (`AsyncMock`).
  - Drive 10 000 `tenant_session(...)` invocations.
  - Measure per-call Python overhead.
  - Assert ≤ 10 µs per call (generous; current ~5).
- Document.

**Locked in by:** the perf test.

**Cross-references:** SH-INV-1 (UUID validation invariant);
RA-INV-1 (same).

**Status:** ✅ closed (2026-05-16). See §5.

---

### PC-02 pool_pre_ping overhead

**Why:** `pool_pre_ping=True` (default in
[`_engine.py:25`](../../src/mgf/sqlalchemy/_engine.py#L25))
sends a `SELECT 1` to the DB on every connection checkout.

Tradeoff:

- **WITH pre-ping**: stale-connection errors are caught
  cleanly + retried. Cost: one RTT per checkout. For an
  app at 100 req/s with checkout-per-request, that's
  100 RTT/sec of pre-ping traffic. On a 1 ms-RTT DB,
  100 ms/sec of DB time spent on pings.
- **WITHOUT pre-ping**: stale connections produce hard
  errors on the first real query after a DB restart /
  firewall reset. Operator deals with the spike.

The default is correct for production reliability; for
high-throughput steady-state apps, operators may opt out
once the app is known stable.

**Concrete output:**

- Document the tradeoff in `create_engine`'s docstring:
  > **`pool_pre_ping=True`** is the default. Every connection
  > checkout sends `SELECT 1` to verify liveness. Cost: one
  > DB roundtrip per checkout. Disable (`pool_pre_ping=False`)
  > only for apps known to handle stale-connection errors
  > correctly + with a documented mitigation (connection
  > timeout, retry logic, etc.).
- Add `tests/perf/test_pool_pre_ping_impact.py`:
  - Build engine with `pool_pre_ping=True`; drive 1000
    checkouts; measure total time.
  - Build engine with `pool_pre_ping=False`; same.
  - Compute the ping overhead.
  - Document (NOT a pass/fail — document the cost).

**Locked in by:** the docstring + the measurement script.

**Cross-references:** SQLAlchemy's docs on pool_pre_ping;
DEP-10 (first-production-cut runbook should mention).

**Status:** ✅ closed (2026-05-16). See §5.

---

### PC-03 Engine construction cost

**Why:** `create_engine(url)` allocates an `AsyncEngine`
without connecting. Cost: ~50-200 µs per construction
(SQLAlchemy's URL parse + engine instantiation +
dialect-loading).

For test fixtures that build fresh engines per test
(rare but happens — strict-isolation patterns), the cost
compounds.

**Concrete output:**

- Add `tests/perf/test_engine_construction.py`:
  - Construct 100 engines (without connecting).
  - Assert per-construction ≤ 200 µs.

**Locked in by:** the perf test.

---

### PC-04 Sessionmaker construction cost

**Why:** `create_sessionmaker(engine)` is a thin wrapper
around `async_sessionmaker`. Cost: ~10-50 µs per call.

**Concrete output:**

- Add `tests/perf/test_sessionmaker_construction.py`:
  - Construct 100 sessionmakers from a shared engine.
  - Assert per-construction ≤ 100 µs.

**Locked in by:** the perf test.

---

### PC-05 RLS policy presence audit cost

**Why:** SH-04 proposes
`assert_rls_policies_present(engine, tables=[...])`. The
helper would query Postgres's `pg_policy` catalog:

```sql
SELECT polname FROM pg_policy WHERE polrelid = '<table>'::regclass;
```

Per table: one catalog query (~100 µs-1 ms). For an app
with 50 tenant-scoped tables, 50 queries at startup.

**Concrete output:**

- When SH-04 ships, add `tests/perf/test_rls_audit_cost.py`:
  - Build 50 tables in a SQLite-mock (or Postgres test
    fixture).
  - Run `assert_rls_policies_present` with all 50.
  - Assert ≤ 500 ms total (10 ms per table generous).
- Document the budget in the helper's docstring.

**Locked in by:** the perf test ships with the helper.

**Cross-references:** SH-04 (the helper this measures).

---

## §5 Closed items

- **PC-01** — `tenant_session` per-call Python overhead pinned
  at ≤ 30 µs/call (measured ~7 µs for str input, ~9 µs for UUID
  input locally; ~3-4x cushion). Tracker proposed 10 µs but
  local measurement comes in at 7-9 µs — 10-40 % cushion would
  have been too tight for CI noise. Budget raised to 30 µs and
  rationale documented in the test. Added
  `tests/perf/test_tenant_session_overhead.py` with two perf-
  marker tests covering both input shapes (str validation path
  + UUID short-circuit path). SQL roundtrip mocked away via
  AsyncMock so the measurement isolates the wrapper's Python
  overhead. (2026-05-16, commit pending.)
- **PC-02** — `pool_pre_ping=True` tradeoff documented in
  `create_engine`'s docstring with a "Performance" Notes section
  describing the per-checkout RTT cost + the when-to-disable
  criteria. Added `tests/perf/test_pool_pre_ping_impact.py` as
  a **documentary** measurement (not a pass/fail gate — the
  real cost is deployment-dependent, dominated by network RTT
  on production Postgres; SQLite in-memory only shows the
  wrapper bookkeeping). The test prints with/without delta
  visible via `pytest -s`, and asserts only that both engines
  built + completed 200 checkouts each. Local measurement
  (SQLite in-memory): ~100 µs / ~37 µs / +63 µs delta. (2026-
  05-16, commit pending.)

- **PC-03** — `create_engine` per-call construction cost pinned at
  ≤ 200 µs/construction. Added
  `tests/perf/test_engine_construction.py` with a single
  perf-marker test that builds 100 SQLite+aiosqlite engines
  back-to-back after a single warm-up construction (to amortise
  the one-time dialect-import cost). Local measurement comes in
  well under the budget (30-60 µs range); the budget is the
  regression-gate, not the target. Engines are disposed after
  the measurement to release driver / pool resources. A breach
  signals real per-call work crept in (URL parse regression,
  dialect resolution slowdown, kwarg-assembly bloat). (2026-05-17,
  commit pending.)
- **PC-04** — `create_sessionmaker` per-call construction cost
  pinned at ≤ 100 µs/construction. Added
  `tests/perf/test_sessionmaker_construction.py` with a single
  perf-marker test that builds 100 sessionmakers from a shared
  engine (the engine cost is excluded — built once before the
  measurement window). Local measurement is in the 5-15 µs range;
  the budget is the regression-gate. A breach signals the RA-03
  collision check or the `async_sessionmaker` forwarding picked
  up real cost. (2026-05-17, commit pending.)

**Infrastructure changes:**

- Registered `perf` marker + addopts deselect in `pyproject.toml`
  (matches mgf-common's convention — perf pins run via
  `pytest -m perf` for the nightly sweep, not per-PR).

---

## §6 Notes on methodology

This first pass walked the two source files (~150 LOC)
against:

- mgf-common's PC tracker methodology.
- SQLAlchemy 2.x's documented per-operation cost
  characteristics.

The audit method:

1. Identify the wrapper's per-call work vs SQLAlchemy's
   per-call work.
2. Pin the wrapper Python overhead separately from the
   SQL roundtrip cost.
3. Document tradeoffs (pre-ping) for consumer-side
   decisions.

Five items + five invariants. The library is small; the
PC shape reflects that. Future passes should look for:

- **SQLAlchemy 2.0 → 3.0 transition.** Major version bumps
  shift internal semantics; per-call cost profiles may
  change. Re-audit.
- **Async-driver evolution.** asyncpg vs psycopg 3 async
  have different per-call profiles; consumer choice matters.
- **Connection-pool sizing under load.** Default sizes may
  not match every consumer's profile; document the tuning
  recipe.

When an item closes, move it to §5 with the closing-commit
SHA + measured baseline.
