Skip to content
Back to skills

Perf Uplift Survey

ASecurity

Survey a feature being ported from V1 to V2 for migration-time performance wins — N+1 queries, missing indexes, unbounded SELECT *, sequential awaits over independent I/O, missing caching, in-app filtering, oversized payloads. Outputs a structured candidate list with cost / saving / parity-preservation argument so each can be decided (applied / deferred / rejected) before V2 ships.

  • 2 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 20, 2026
ai-agentspythongobashsqlapidatabasefrontendbackendperformance

Works with

  • cli
  • api

Security analysis

A100/100

Scanned October 20, 2026

npx -y skills add adnanmokhtar/refract --skill perf-uplift-survey --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Perf Uplift Survey?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Perf Uplift Survey
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/adnanmokhtar-perf-uplift-survey/badge)](https://www.skillsdirectory.com/skills/adnanmokhtar-perf-uplift-survey)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: perf-uplift-survey
description: Survey a feature being ported from V1 to V2 for migration-time performance wins — N+1 queries, missing indexes, unbounded SELECT *, sequential awaits over independent I/O, missing caching, in-app filtering, oversized payloads. Outputs a structured candidate list with cost / saving / parity-preservation argument so each can be decided (applied / deferred / rejected) before V2 ships.
kind: skill
pack: migration
allowed-tools: [Read, Grep, Glob, Bash]
---

# perf-uplift-survey

The port is the cheapest moment to apply performance improvements: V2's code is being written anyway; V2's data access is being re-shaped anyway; V1 + V2 cohabitation lets you A/B perf measurements directly. This skill systematises the survey so wins are caught and decided, not improvised.

## Premise

Find real wins, no hand-waves. Every candidate cites V1's offending site (`<v1-path:line>`) and V2's transformation site (`<v2-path:line>`), with a measurement: query count, p95 latency, bytes-on-wire, plan output from `migration-rehearsal`. "This will be faster" without before/after measurement is a hypothesis, not a candidate. Parity preservation is argued per row against the contract's Caller assumptions and Outputs sections — not asserted. New infrastructure (cache layer, parallelism primitive, client wrapper) goes through the V2 anchors check before being added; do not invent a parallel primitive when V2 declares one.

## Halt conditions

- Halt on candidates without V1 + V2 path citations and a measured (or rehearsed) before/after.
- Halt on "applied" rows whose parity tests didn't run pre- and post-change.
- Halt on perf changes that introduce a primitive (per-feature HTTP client instance, per-feature connection pool, hand-rolled cache) when `_v2-anchors.md` declares one for the same concern.
- Halt on contract-breaking perf changes (ordering, nullability, sync→async side effect) without an ADR + caller migration plan.

This skill is the procedural arm of `migration-discipline.md` § "Should — Migration-time perf uplift" + `feature-port.md` Phase 5.

## When to use

- Phase 5 of a per-feature port (after V2 is written + parity tests green; before cutover).
- Re-running on a feature whose V2 shipped but where prod telemetry shows missed perf opportunities.
- Auditing a queue of pending features to estimate aggregate migration-time perf gain (e.g., "if we apply all parity-preserving uplifts, what's the projected p95 reduction?").

## When NOT to use (refuse + explain)

- Before V2 is written — the uplift survey reads V2's plan/code, not just V1's; running it without a V2 design produces speculative recommendations.
- On a feature whose parity tests are not green — the survey assumes parity is a fixed point; uplift on a broken parity baseline produces faster-but-still-broken code.
- As a substitute for the `performance` pack's profile-based audit — this skill catches *migration-time* wins (the ones that are cheap because we're rewriting anyway). Steady-state perf optimisation belongs in the `performance` pack.

## Prerequisites

- Feature has a contract (`ai/migration/contracts/<feature>.md`) — the **Performance characteristics** + **Side effects** sections are read directly.
- V2 implementation exists (or, at minimum, V2's plan with concrete data-access decisions).
- `_extracted-codebase.md § Performance hot paths` + `§ Concurrency primitives` populated.
- Access to: V1 query logs (or `EXPLAIN` against prod-sized data), V1's cache config (Redis / in-process), V1's pool sizes, V1's API client configs.

## Procedure

For each candidate below, examine V1 + V2's plan, classify, and emit a candidate row in `ai/migration/perf-decisions/<feature>.md`.

### 1. N+1 queries

```text
V1 trigger:                                 V2 transformation (parity-preserving):
  for o in orders:                            customer_ids = unique(o.customer_id for o in orders)
    c = getCustomer(o.customer_id)            cust_by_id = getCustomersByIds(customer_ids)  # 1 query, IN clause
    o.customer = c                            for o in orders:
                                                o.customer = cust_by_id[o.customer_id]
```

- Detect: V1's call graph shows a function called inside a loop with each iteration's input being independent.
- Estimate: V1 cost = N round-trips × per-query latency + DB connection holding time. V2 cost = 1 round-trip with `IN` of size N (capped — split into chunks of e.g., 500 if N > 1000 to stay within DB stmt limits).
- Parity: same returned data, just fewer round-trips. Order may differ — assert tolerance is `order_insensitive` or use a dict-lookup pattern.

### 2. Missing or wrong indexes (for V2's query shape)

V2's query shape often differs from V1's because V2 reorganises queries (joins moved, predicates added, sorts added). The pre-existing index on V1 may not cover V2.

- Detect: run `EXPLAIN ANALYZE` on V2's queries against prod-sized data (use `database/skills/migration-rehearsal/SKILL.md`). Look for `Seq Scan` on tables > 10K rows where a `WHERE` clause exists.
- Estimate: V1 with index = O(log N) lookup; V2 sequential scan = O(N). At 10M-row tables this is the difference between 1ms and 1500ms.
- Apply: write a reversible migration (`CREATE INDEX CONCURRENTLY ...` on Postgres; equivalent on other engines). Composite index ordering matters — leftmost columns are the most-selective predicates.
- Parity: same rows returned, faster. Always parity-preserving (no observable change).
- Caveat: index on a write-heavy table costs write latency. Measure both directions before committing.

### 3. Column projection (`SELECT *` → minimal columns)

V1 frequently uses `SELECT *` or full ORM model hydration even when the consumer reads 3 fields. V2 can project minimally.

- Detect: V1's query returns full row → consumer reads `<small subset>`. ORM models with 30+ columns are common in legacy code.
- Estimate: bytes-on-wire reduction (often 70–90%) + ORM hydration CPU + GC pressure (object construction is the hidden cost).
- Apply: V2 returns a DTO with only the consumed columns. Update parity tolerance to `superset` (V1 returned more fields; V2 returns the documented consumed subset; parity test asserts exact match on the documented columns).
- Parity: ONLY parity-preserving if the consumers truly don't read the dropped columns. The contract's **Caller assumptions** section is the authoritative answer — re-check before applying. If any consumer reads a dropped column, this is a contract break (ADR + caller migration plan).

### 4. Caching (the user's specific concern)

Three caching tiers, decide per call site:

| Tier | Use when | Examples |
|---|---|---|
| **Per-request memoisation** | Same lookup happens multiple times within one request | `getUser(id)` called by 5 different handlers in one request — memoise on a request-scoped map |
| **Cross-request in-process cache** | High-frequency, low-staleness-tolerance lookups; data fits in process memory | Feature flag config, currency rates updated hourly |
| **Cross-request distributed cache (Redis / Memcached)** | High-frequency, can tolerate stale-by-TTL, must be shared across replicas | Computed aggregates, expensive joins, session data |

For each call site, capture:
- **Call rate**: requests/sec hitting the underlying source. From V1 telemetry or estimate from feature traffic.
- **Payload size**: average + p95 bytes per cached item. (Affects cache memory budget.)
- **Staleness tolerance**: how stale can the data be? (Sets TTL.) Some data is "tolerable for minutes" (lookup tables); some is "must be live" (account balance) — the latter doesn't cache without an event-driven invalidation rule.
- **Invalidation rule**: TTL-only? Event-driven (on row update)? Both? Document explicitly. Cache without invalidation rule is a bug waiting to happen.
- **Hit-rate projection**: from call-rate + key-cardinality + TTL.

Apply: V2 wraps the underlying source with the cache. Parity tests still hit the underlying source (not the cache) by default — separate cache-enabled tests verify the cache layer is correct.

Reject if: the cached value depends on user-specific authz (cache key must include user/tenant), or the TTL window allows a stale value to violate a business rule (e.g., a price cache with 1h TTL on a feature where prices can change immediately).

### 5. Sequential awaits over independent I/O

(Cross-references `backend/skills/parallelize-independent-ops/SKILL.md`.)

- Detect: V1's call graph shows `for x in xs: await f(x)` where `f`'s arguments don't depend on prior iterations.
- Estimate: wall-clock = N × per-call latency. Parallel wall-clock = max-call latency + bounded overhead. For N=10, p95=100ms each, sequential = 1000ms, parallel = ~120ms.
- Apply: V2 uses bounded parallelism via the project's primitive. Cap = ≤ 8 for HTTP, ≤ pool-size for DB.
- Parity: parity-preserving IF iterations are truly independent (the rule's "data dependency" check). Order of completion may differ — tolerance must be `order_insensitive` if the contract pins order, otherwise tighter.

### 6. In-app filtering / sorting → DB filtering / sorting

```text
V1:                                          V2:
  rows = db.findAll(Orders)                    rows = db.findWhere(Orders, status='ACTIVE')
  active = [r for r in rows if r.active]       # done — DB returned only active rows
```

- Detect: V1 fetches a wide set then filters in app code; or fetches then sorts in app code.
- Estimate: row reduction (often 90%+) + bytes-on-wire + ORM hydration.
- Parity: same rows after filter; faster.
- Apply: push the filter / sort to the DB. Add the index for the filter column if missing.

### 7. Batched inserts / updates

```text
V1: for row in rows: db.insert(row)          V2: db.bulkInsert(rows, chunk_size=500)
```

- Detect: loop with single inserts/updates.
- Estimate: round-trip reduction (commonly 50–100×). Be careful with constraint validation — bulk inserts skip per-row triggers in some ORMs.
- Parity: same data persisted; check trigger / ON CONFLICT semantics carefully.

### 8. Avoid hydrating into ORM models when raw values suffice

```text
V1: users = ORM.find()  # hydrates 30 fields × Python class instance overhead
V2: rows = sql_select_dict('SELECT id, name FROM users WHERE ...')
```

- Detect: V1 hits an ORM.find then immediately maps to a DTO using a few fields.
- Estimate: ORM hydration CPU + memory; can be 5–10× faster on hot paths.
- Parity: ORM-side validation is bypassed — only safe when the contract doesn't depend on it (read paths typically don't; write paths usually do).

### 9. Move synchronous external HTTP off the hot path

```text
V1 hot path: POST /orders → store order → call externalEmail(api) → return 200
V2 hot path: POST /orders → store order → enqueue(emailJob) → return 200
                                                                ↓
V2 background:                                                  → emailJob → call externalEmail(api)
```

- Detect: a hot-path handler does external HTTP synchronously, with a retry/timeout that costs the user request budget.
- Estimate: p95 reduction equal to externalEmail's p95.
- Parity: usually a contract break (the email is now eventually sent rather than guaranteed-before-response). Requires ADR + caller migration if any caller depends on synchronous side-effect ordering.
- Reject if: the side effect MUST land before response (e.g., audit log for compliance). In those cases, accept the latency or use a same-transaction outbox pattern (own ADR).

### 10. Compression / payload shape (API responses)

- Detect: V1 returns a 50KB JSON for a list endpoint; V2's column projection brings it to 5KB. With gzip/br compression, on-the-wire savings are smaller — but the JSON-construction CPU is significant.
- Apply: V2 returns minimal DTOs; enable compression at the framework level.
- Parity: caller assumes `superset` shape; enforce via the contract.

## Output format

`ai/migration/perf-decisions/<feature>.md`:

```markdown
# Perf decisions: <feature>

> Phase: 5 (perf uplift) | Status: in progress / final
> Contract: ai/migration/contracts/<feature>.md
> Run by: <name> | Reviewed by: <name>

## Candidates

### 1. N+1 in customer lookup — APPLIED
- **V1 path**: `Reports/views.py:472-498` — `for o in orders: c = Customer.objects.get(id=o.customer_id)`.
- **V2 transformation**: bulk `Customer.objects.filter(id__in=customer_ids)` then dict-by-id lookup.
- **Cost (V1)**: ~15 queries per request (N=15 typical, max=50). p95 latency contribution: 75ms.
- **Saving (V2 measured)**: 1 query per request. p95 contribution: 8ms. Net savings: 67ms p95.
- **Parity**: order_insensitive on response.customers list (already declared in tolerance.yaml).
- **Decision**: applied in V2 (`<v2-path:line>`).
- **Verification**: parity tests green; before/after p95 measured.

### 2. Missing index on (status, created_at) — APPLIED
- **V1 query**: `WHERE status = ? AND created_at > ?` covered by single-column `(status)` index — leftmost match only.
- **V2 query**: same, but called 5× more frequently due to V2's pagination.
- **Plan rehearsal** (`migration-rehearsal`): without composite index, seq scan on 12M rows, 1800ms; with composite (status, created_at), index scan, 12ms.
- **Migration**: `db/migrations/2026_04_27_orders_status_created_idx.sql` — `CREATE INDEX CONCURRENTLY ...`.
- **Decision**: applied. Reversible via `DROP INDEX CONCURRENTLY` if reverted.

### 3. SELECT * → 4 columns — APPLIED
- **V1**: `SELECT * FROM users` — returns 47 columns; consumer reads 4 (id, name, email, status).
- **V2**: explicit projection.
- **Saving**: ~85% bytes on wire; ~70% ORM hydration CPU.
- **Parity**: response.user is now a 4-field object. Tolerance updated to assert exact match on the 4 documented fields; ignores absence of the other 43 (consumers verified to not read them — see contract's Caller assumptions).
- **Decision**: applied.

### 4. Per-request cache for getUser — APPLIED
- **Detect**: getUser called 7× per request on average across handlers.
- **Cache**: request-scoped Map keyed on userId.
- **Invalidation**: per-request automatic (Map dies with request).
- **Saving**: ~6 queries per request avoided.
- **Decision**: applied.

### 5. Cross-request distributed cache for tax rate — DEFERRED
- **Detect**: getTaxRate(jurisdiction) called per order; rates change infrequently.
- **Plan**: distributed cache (the project's shared-cache primitive), TTL 6h, invalidate on tax-config-update event.
- **Blocker**: shared-cache infra not in V2's stack yet; ETA 2026-05-15.
- **Decision**: deferred until infra lands. Captured here so it isn't forgotten; ledger row notes `deferred-perf` flag.

### 6. Move email send off hot path — REJECTED
- **Detect**: POST /orders synchronously calls the email vendor; p95 contribution 250ms.
- **Plan**: enqueue email job; respond immediately; worker sends.
- **Reject reason**: contract break — current callers expect "if 200 OK, email is sent". Compliance team requires synchronous send for tax-receipt orders. ADR-014 explored async; rejected. Defer indefinitely.

## Summary

- **Applied**: 4 (rows 1, 2, 3, 4)
- **Deferred**: 1 (row 5)
- **Rejected**: 1 (row 6)
- **Aggregate measured saving (applied)**: -73% p95 latency (820ms → 220ms), -82% queries/call (24 → 4), -84% bytes/response.
- **Indexes added**: 1 (composite on orders(status, created_at)).
- **Cache layers added**: 1 (per-request).
- **Parity tests**: green pre-uplift, green post-uplift, green at each candidate's commit.
```

## Anti-pattern: structural perf

The port is not a license to introduce V1-shaped code in V2 under a "perf" label. The most common manifestation is ironic — a "perf cache" is added and lands as a parallel HTTP client instance with its own interceptors (frontend), or a per-feature connection pool that bypasses the canonical client (backend). The cache may be faster; the structure violates V2's layering and is a copy-paste of V1's worst habit.

The validator's `check_v2_structure` catches V1 copy-paste fingerprints — recognisable shapes from V1 root that should not appear under V2 root (e.g., the per-stack fingerprint catalogue lives in `frontend/rules/migration-frontend.md` / `backend/rules/migration-backend.md`). `check_composable_reuse` flags open-coded reusables where the project's shared inventory has one; `check_service_shape` flags direct HTTP-client calls where a service should mediate; `check_lifecycle_keepalive` flags wrong-hook-on-cached-route per the project's anchors.

Before applying any perf candidate that adds new infrastructure (cache layer, parallelism primitive, client wrapper, query helper), check:

1. Does V2's layering already declare a primitive for this concern? (`ai/migration/_v2-anchors.md` lists them.) If yes, extend that primitive — do NOT create a parallel one.
2. Does the perf change land in the same layer the contract assigns the side effect to? A cache moved from "service layer" to "component body" is a layering break dressed as perf.
3. Is the primitive shared across features? A per-feature HTTP client instance for one cached endpoint becomes 30 such instances by the end of the migration; each one is its own refresh-token bug, its own interceptor drift, its own audit failure.

If a perf gain genuinely needs new infrastructure, it's a separate ADR + separate PR, not a smuggled bullet under "perf uplift". Cross-references: see `migration-discipline.md` § "The Buried Perf Improvement" anti-pattern.

## Failure modes

- **Speculative perf claims**: "this is faster" without measurement = noise. Every applied row needs a measured before/after OR a `migration-rehearsal` plan output.
- **Smuggled contract breaks**: a "perf change" that quietly changes ordering / nullability / ID semantics. The parity test should catch it; if the test passes, the test is incomplete — re-extract the contract.
- **Cache-without-invalidation**: TTL-only caching of data that needs immediate invalidation (e.g., user permissions, prices). Flag and reject.
- **Parallelism without bounds**: applying recipe 5 with unbounded `Promise.all` over user-controlled arrays. Always bound per `concurrency-discipline.md`.
- **Index-everything**: indexes are not free — write latency cost. Measure before committing.
- **Optimising the wrong thing**: a 10ms saving in a request whose p95 is 800ms because of one external call. Profile first; optimise where the wall-clock is.
- **Skipping the deferred / rejected list**: discoveries that don't apply are still valuable — they prevent re-rediscovery + inform future work. Always populate the list.

## Related

- `migration-discipline.md` — the rule mandating this survey.
- `feature-port.md` — Phase 5 of the per-feature lifecycle.
- `extract-v1-contract.md` — produces the V1 baseline data this skill consumes.
- `parity-test-generate.md` — the parity tests this skill MUST keep green.
- `backend/skills/parallelize-independent-ops/SKILL.md` — the parallel-I/O recipe (this skill's row 5).
- `backend/rules/concurrency-discipline.md` — the bound + safety rules for parallel I/O.
- `database/skills/migration-rehearsal/SKILL.md` — measures V2 query plans on prod-sized data; output feeds rows 2 + 6.
- `database/agents/query-optimizer.md` — pair-program with this skill on rows 1, 2, 6, 7.
- `performance/_examples/perf-audit.md` — for steady-state perf work after migration; complements but doesn't replace this skill.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.