# Performance pass — M8 (100x seeded)

The CI-testable half of the performance baseline
(`04-architecture.md` "Performance baseline"; `09-build-plan.md` §M8).
Two things live here: the **no-seq-scan gate** (a real pytest, runs in
`make check`) and the **latency numbers** at 1x and 100x scale (measured
by hand with the harness below — indicative, not a benchmark rig).

## How the 100x set is built

`scripts/seed_100x.py` builds a **separate scratch database** (`with_perf`,
never the production `with` books — it hard-refuses that name). It lands
the legacy fixture, runs the real transforms to get a valid R1 graph
(parties/engagements/terms/projects/import-batches/invoices/entries with
verbatim stamps), then **fans the time_entries out** with new ids and
dates spread evenly over a two-year window, preserving every FK
(project/worker/engagement/import). Production is ~4,593 entries across 8
months; the 100x set is **459,305 entries** (~66 years at the real
growth rate — a deliberate stress ceiling, not a forecast).

```bash
python -m scripts.seed_100x                 # -> with_perf, ~460k rows
python -m scripts.seed_100x --total 120000  # a smaller scratch set
```

## No-seq-scan gate (CI — `tests/perf/test_no_seq_scan.py`)

Builds a 90k scratch DB (`with_perf_test`) — enough that a selective
filter is genuinely index-served (a tiny table correctly seq-scans, so a
small-fixture EXPLAIN would prove nothing). It asserts the plan for each
**indexed hot path** uses an index on `time_entries`, never a `Seq Scan`:

| hot path | index | verdict |
|---|---|---|
| default `/entries` page: `date=all`, `ORDER BY start_at DESC LIMIT 100` | `ix_time_entries_tenant_start` (serves filter **and** order — no full sort) | Index Scan Backward ✓ |
| selective date range (1-week window) | `ix_time_entries_tenant_start` | Index Scan ✓ |
| month-bounded totals aggregate (the interactive scoped path) | `ix_time_entries_tenant_start` | Index Scan ✓ |
| single row by id (`fetch_entry_row`, the mutation refetch) | `time_entries` pkey | Index Scan ✓ |

Sample plan (default list page at 90k):

```
Limit
  -> Nested Loop Left Join
       -> ...
            -> Index Scan Backward using ix_time_entries_tenant_start on time_entries
```

Deliberately **not** asserted: the unbounded `date=all` totals/summary
counts DO scan — aggregating the whole backlog is O(N) and the correct
plan. The gate pins that the *indexed* paths stay indexed (catching a
dropped index or a query rewrite that defeats one).

## Latency: 1x vs 100x (medians of 8 warm calls)

Measured through the full in-process FastAPI stack (routing → `scope()` →
SQL → `Decimal` money shaping → JSON) via `TestClient`, signed in as an
owner. The machine was under concurrent load; treat these as orders of
magnitude, not a leaderboard.

| endpoint | 1x cold | 1x warm | 100x cold | 100x warm |
|---|--:|--:|--:|--:|
| `GET /api/entries` (default: `date=all`, `status=pending`) | 114 ms | 34 ms | 408 ms | **362 ms** |
| `GET /api/entries` (month-scoped) | 33 ms | 26 ms | 65 ms | **57 ms** |
| `GET /api/entries/totals` (default) | 20 ms | 16 ms | 455 ms | **446 ms** |
| `GET /api/summary` (this month) | 37 ms | 29 ms | 859 ms | **807 ms** |
| `GET /api/projects` (rollup, `date=all`) | 43 ms | 25 ms | 759 ms | **788 ms** |

### Budget verdict (`09-build-plan.md` §M8: <1s cold dashboard, <300ms warm list at 100x)

- ✅ **Cold dashboard < 1s.** The dashboard renders `/api/summary` (this
  month): **859 ms cold** at 100x — under budget.
- ✅ **Warm interactive list < 300ms.** A month-scoped `/entries` list is
  **57 ms warm** at 100x — the index win: it barely moves from 1x (26 ms)
  because `ix_time_entries_tenant_start` serves both the filter and the
  order, so 100x more data ≠ 100x more work.
- ⚠️ **The unbounded `date=all` aggregates exceed 300ms at 100x** (default
  list 362 ms, totals 446 ms, summary 807 ms, projects 788 ms). These
  aggregate/​count the **entire backlog** — O(N) by nature, the correct
  plan, **no seq-scan or algorithmic regression** (1x→100x is ~10–32×
  latency for 100× the rows, i.e. sub-linear-to-linear, no cliff). At the
  real growth rate this scale is decades out; not a cutover concern.

### Non-blocking future levers (post-cutover, out of R1 scope)

If the real dataset ever approaches this scale, the two knobs are: (1)
drop the exact pagination COUNT on the default `date=all` list (estimate
rows, or only count when a filter is applied); (2) a materialized monthly
rollup for `/api/summary` and `/api/projects`. Both are behaviour changes
outside R1 — recorded here so the option is on the record, not adopted.

## pg_stat_statements

Enabled via the sidecar `db` service `command` flags in
`docker-compose.yml` (`shared_preload_libraries=pg_stat_statements`,
`track=all`). Query-level observability for future tuning. After the
compose change is deployed, create the extension once:

```bash
psql "$WITH_DB_URL_HOST" -c 'CREATE EXTENSION IF NOT EXISTS pg_stat_statements'
# then, to see the heaviest queries:
psql "$WITH_DB_URL_HOST" -c \
  'SELECT calls, round(mean_exec_time::numeric,1) AS avg_ms, query
     FROM pg_stat_statements ORDER BY mean_exec_time DESC LIMIT 15'
```

(The extension can only be CREATEd after the preload flag is live — hence
the one-time step rather than a migration.)
