Skip to content

Commit f89e278

Browse files
Merge pull request #26 from thelastbackspace/feat/pool-and-index
Pool exhaustion, missing indexes, and query attribution under a full pool
2 parents 944792b + db280fe commit f89e278

17 files changed

Lines changed: 590 additions & 10 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
- **Queries were attributed to the wrong request when a connection pool was full.** pg-pool, mysql2 and mysql call a queued request's callback from whichever request released a connection, so its queries landed in that other request's tree, or in none. Pool callbacks, and every callback a tracked driver call takes, now keep the caller's context. With a pool of 1 and 4 concurrent requests, 3 of the 4 showed no queries before (found while adding pool-wait tracking).
12+
1013
### Fixed (found by the new evaluation set)
1114
- `run` reported each of several `Promise.all` calls as "dominant" (each spans most of the request). Parallel calls are no longer dominant, and a dominant call must take at least 50 ms (`dominantMinMs`).
1215
- Blocking code was reported three times: as blocking, as its own route queueing, and as the process being busy. Only the bystanders that waited behind it are listed now.
1316
- `traceperf/auto` wrote each request tree with its own `appendFileSync`, and under load that showed up as the app's "CPU cost" (`writeFileUtf8`). Writes are batched every 20 ms.
1417

1518
### Added
19+
- **Missing indexes:** `traceperf run` EXPLAINs (plans, never runs) the app's Postgres `SELECT`s after the load and reports full scans of tables with 10,000+ rows that an index would avoid, with the `CREATE INDEX` to add. `--no-explain` turns it off.
20+
- **Pool exhaustion:** `pg` and `mysql2` pools track the wait for a free connection when every connection is in use (`pg pool wait`, `mysql pool wait`); `run`/`explain` report it with the setting to change.
1621
- **GitHub Action** (`uses: thelastbackspace/traceperf@<tag>`): runs the baseline check, fails the job on more calls per request, and posts one pull-request comment it updates on every push. `traceperf run --markdown file.md` writes the same summary.
1722
- `traceperf run` reads `traceperf.config.json` (or `--config file`); command-line options win. A `login` step logs in before the traffic and sends the returned token or cookies with every request; `${NAME}` in the file is read from the environment.
1823
- `npm run eval` scores `traceperf run` on 20 apps in `examples/slow-apps`: 12 with a planted problem, 8 clean ones where the right answer is "no issues". CI fails on any missed problem or false alarm. Before these fixes: 17/17 found, 15 false alarms; after: 17/17, 0.

‎README.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,14 @@ The database was fast; `buildReport` was blocking it. That only shows up when re
9393
fix: Mongoose is building full documents: add .lean() to read-only queries … and .select() only the fields you send
9494
```
9595
- **Timings you can trust:** after the warm-up, `run` sends a few requests one at a time (`--probe`) and takes the per-request findings (a call taking most of a request, sequential awaits, N+1) from those. Under concurrent load every `await` also waits for the busy event loop, which makes fast queries look slow.
96+
- **Missing indexes (Postgres):** after the load, the app runs plain `EXPLAIN` (the query is planned, not executed) on the `SELECT`s it made and reports full scans of big tables that an index would avoid:
97+
98+
```
99+
🔍 SELECT * FROM orders WHERE customer_id = $1 (run 1219 times) reads all ~60,000 rows of orders to return ~12 (Seq Scan, Filter: (customer_id = 0))
100+
fix: add an index: CREATE INDEX ON orders (customer_id)
101+
```
102+
Postgres scans small tables on purpose, so a test database with little data rarely shows one: point the run at realistic data. Tables under 10,000 rows are ignored. `--no-explain` turns it off.
103+
- **Pool exhaustion:** with `pg` and `mysql2`, the time a request waits for a free connection from a full pool is tracked (`pg pool wait`) and reported, with the pool setting to change: "GET /report waited 65 ms for a free pg connection (79% of the request): every connection in the pool was in use".
96104
- **Wrong setup is said first:** "2117 of 2117 responses failed (most: 401 Unauthorized)…" instead of timings of error pages.
97105

98106
Checked on real apps, not only on the planted ones: Prisma's REST example, the RealWorld API (Express + Prisma), an Express + Mongoose boilerplate and a NestJS + TypeORM app.

‎examples/slow-apps/cases.json‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,5 +214,43 @@
214214
"/flags"
215215
],
216216
"expect": []
217+
},
218+
"pool-exhaustion": {
219+
"paths": [
220+
"/report"
221+
],
222+
"requires": "pg",
223+
"expect": [
224+
{
225+
"kind": "pool",
226+
"includes": "for a free pg connection"
227+
}
228+
]
229+
},
230+
"missing-index": {
231+
"paths": [
232+
"/orders"
233+
],
234+
"requires": "pg",
235+
"expect": [
236+
{
237+
"kind": "index",
238+
"includes": "traceperf_eval_orders"
239+
}
240+
]
241+
},
242+
"clean-indexed": {
243+
"paths": [
244+
"/orders"
245+
],
246+
"requires": "pg",
247+
"expect": []
248+
},
249+
"clean-pool": {
250+
"paths": [
251+
"/report"
252+
],
253+
"requires": "pg",
254+
"expect": []
217255
}
218256
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// Clean: the fixed version of missing-index (customer_id is indexed). Needs TRACEPERF_PG_URL.
2+
const serve = require('../serve');
3+
const setup = require('../missing-index/setup');
4+
5+
setup('traceperf_eval_orders_indexed', true).then(pool => {
6+
let n = 0;
7+
serve({
8+
'/orders': async () => (await pool.query('SELECT * FROM traceperf_eval_orders_indexed WHERE customer_id = $1', [n++ % 5000])).rows,
9+
});
10+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// Clean: the fixed version of pool-exhaustion (pool of 20 for 10 concurrent requests). Needs TRACEPERF_PG_URL.
2+
const pg = require('pg');
3+
const serve = require('../serve');
4+
5+
const pool = new pg.Pool({ connectionString: process.env.TRACEPERF_PG_URL, max: 20 });
6+
7+
serve({
8+
'/report': async () => (await pool.query('SELECT pg_sleep(0.015), now() AS at')).rows,
9+
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// Planted cause: orders are looked up by customer_id, which has no index:
2+
// every request makes Postgres read all 60,000 rows. Needs TRACEPERF_PG_URL.
3+
const serve = require('../serve');
4+
const setup = require('./setup');
5+
6+
setup('traceperf_eval_orders', false).then(pool => {
7+
let n = 0;
8+
serve({
9+
'/orders': async () => (await pool.query('SELECT * FROM traceperf_eval_orders WHERE customer_id = $1', [n++ % 5000])).rows,
10+
});
11+
});
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// A 60,000-row orders table (created once), with or without an index on customer_id
2+
const pg = require('pg');
3+
4+
module.exports = async function setup(table, indexed) {
5+
const pool = new pg.Pool({ connectionString: process.env.TRACEPERF_PG_URL, max: 10 });
6+
await pool.query(`CREATE TABLE IF NOT EXISTS ${table} (id int PRIMARY KEY, customer_id int, total numeric, created_at timestamptz)`);
7+
const { rows } = await pool.query(`SELECT count(*)::int AS n FROM ${table}`);
8+
if (rows[0].n === 0) {
9+
await pool.query(`INSERT INTO ${table} SELECT g, g % 5000, g * 1.5, now() - g * interval '1 minute' FROM generate_series(1, 60000) g`);
10+
}
11+
if (indexed) await pool.query(`CREATE INDEX IF NOT EXISTS ${table}_customer ON ${table} (customer_id)`);
12+
await pool.query(`ANALYZE ${table}`);
13+
return pool;
14+
};
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// Planted cause: a pg pool of 2 connections serving 10 concurrent requests,
2+
// each holding its connection for a ~15 ms query: most of every request is
3+
// waiting for a free connection, not querying. Needs TRACEPERF_PG_URL.
4+
const pg = require('pg');
5+
const serve = require('../serve');
6+
7+
const pool = new pg.Pool({ connectionString: process.env.TRACEPERF_PG_URL, max: 2 });
8+
9+
serve({
10+
'/report': async () => (await pool.query('SELECT pg_sleep(0.015), now() AS at')).rows,
11+
});

‎src/__tests__/drivers.test.ts‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
instrumentRedis,
2626
sqlLabel,
2727
} from '../index';
28+
import { observeSql, ObservedSql } from '../drivers';
2829

2930
function setup() {
3031
const roots: ExecutionRecord[] = [];
@@ -477,3 +478,81 @@ describe('instrumentBetterSqlite3 (database shaped like better-sqlite3: Statemen
477478
expect(labels(roots[0])).toEqual([]);
478479
});
479480
});
481+
482+
describe('pg pools without a server (fake pg)', () => {
483+
// Shaped like pg: Client.query(text, values, cb) and pg-pool's counters and callback queue
484+
function fakePg() {
485+
class Client {
486+
query(text: string, values: unknown, cb?: (e: unknown, r: unknown) => void) {
487+
const done = typeof values === 'function' ? values as typeof cb : cb;
488+
const result = { rows: [{ 'QUERY PLAN': [{ Plan: { 'Node Type': 'Seq Scan' } }] }] };
489+
if (done) { setTimeout(() => done(null, result), 2); return undefined; }
490+
return new Promise(r => setTimeout(() => r(result), 2));
491+
}
492+
}
493+
class Pool {
494+
options = { max: 1 };
495+
totalCount = 0;
496+
idleCount = 0;
497+
private queue: Array<(e: unknown, c: Client, release: () => void) => void> = [];
498+
private client = new Client();
499+
connect(cb: (e: unknown, c: Client, release: () => void) => void) {
500+
const release = () => {
501+
const next = this.queue.shift();
502+
// The next waiter is called from inside this release: another request's context
503+
if (next) next(null, this.client, release);
504+
else this.idleCount = 1;
505+
};
506+
if (this.totalCount < this.options.max) { this.totalCount++; setTimeout(() => cb(null, this.client, release), 1); return; }
507+
if (this.idleCount > 0) { this.idleCount = 0; setTimeout(() => cb(null, this.client, release), 1); return; }
508+
this.queue.push(cb);
509+
}
510+
query(text: string, values?: unknown[]) {
511+
return new Promise((resolve, reject) => this.connect((e, c, release) => {
512+
if (e) return reject(e);
513+
(c.query(text, values) as Promise<unknown>).then(r => { release(); resolve(r); }, reject);
514+
}));
515+
}
516+
}
517+
return { Client, Pool };
518+
}
519+
520+
test('a full pool: each request keeps its own queries, and the wait is tracked', async () => {
521+
const pg = fakePg();
522+
const { tp, roots } = setup();
523+
const uninstall = instrumentPg(tp, pg);
524+
const pool = new pg.Pool();
525+
try {
526+
await Promise.all([1, 2, 3].map(i => tp.track(async () => {
527+
await pool.query(`SELECT ${i}`);
528+
}, { label: `req${i}` })));
529+
} finally {
530+
uninstall();
531+
}
532+
const requests = roots.filter(r => r.label.startsWith('req'));
533+
expect(requests).toHaveLength(3);
534+
for (const r of requests) expect(labels(r).filter(l => l !== 'pg pool wait')).toEqual(['pg SELECT']);
535+
expect(requests.filter(r => labels(r).includes('pg pool wait')).length).toBeGreaterThanOrEqual(1);
536+
});
537+
538+
test('observeSql sees statements and runs its own on the same pool, untracked', async () => {
539+
const pg = fakePg();
540+
const { tp, roots } = setup();
541+
const uninstall = instrumentPg(tp, pg);
542+
const seen: ObservedSql[] = [];
543+
observeSql(q => seen.push(q));
544+
const pool = new pg.Pool();
545+
try {
546+
await tp.track(() => pool.query('SELECT * FROM users WHERE id = $1', [7]), { label: 'job' });
547+
expect(seen.map(q => [q.text, q.values])).toEqual([['SELECT * FROM users WHERE id = $1', [7]]]);
548+
const rows = await seen[0].run('EXPLAIN (FORMAT JSON) SELECT 1');
549+
expect(rows[0]['QUERY PLAN']).toBeDefined();
550+
// The EXPLAIN was neither observed again nor tracked as the app's query
551+
expect(seen).toHaveLength(1);
552+
} finally {
553+
observeSql(undefined);
554+
uninstall();
555+
}
556+
expect(roots.map(r => r.label)).toEqual(['job']);
557+
});
558+
});

‎src/__tests__/run.test.ts‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,9 @@ const cases: Record<string, { paths: string[]; expect: Array<{ kind: string | st
137137
JSON.parse(fs.readFileSync(path.join(APPS, 'cases.json'), 'utf8'));
138138

139139
describe('known-slow example apps: explain names the planted cause', () => {
140-
it.each(Object.keys(cases))('%s', async name => {
140+
// Cases that need a real database run where CI provides one (TRACEPERF_PG_URL)
141+
const runnable = Object.keys(cases).filter(name => !(cases[name] as { requires?: string }).requires || !!process.env.TRACEPERF_PG_URL);
142+
it.each(runnable)('%s', async name => {
141143
const c = cases[name];
142144
const out: string[] = [];
143145
const argv = [path.join(APPS, name, 'server.js'), ...c.paths.flatMap(p => ['--path', p]), '-d', '1s', '--json'];
@@ -430,3 +432,24 @@ describe('markdownReport', () => {
430432
expect(markdownReport({ trees: 5, findings: [] })).toMatch(/^### traceperf: no issues found/);
431433
});
432434
});
435+
436+
describe('indexFindings', () => {
437+
const { indexFindings } = jest.requireActual('../run');
438+
const scan = (over: object) => ({ table: 'orders', filter: '(customer_id = 5)', rows: 12, columns: ['customer_id'], tableRows: 60000, ...over });
439+
it('reports selective full scans of big tables, once per index', () => {
440+
const [f, ...rest] = indexFindings([
441+
{ driver: 'pg', text: 'SELECT * FROM orders WHERE customer_id = $1', count: 500, scans: [scan({})] },
442+
{ driver: 'pg', text: 'SELECT id FROM orders WHERE customer_id = $1', count: 20, scans: [scan({})] },
443+
]);
444+
expect(rest).toEqual([]);
445+
expect(f.kind).toBe('index');
446+
expect(f.message).toMatch(/\(run 500 times\) reads all ~60,000 rows of orders to return ~12/);
447+
expect(f.fix).toMatch(/^add an index: CREATE INDEX ON orders \(customer_id\)/);
448+
});
449+
it('small tables, unselective filters and filters without usable columns are fine', () => {
450+
expect(indexFindings([{ driver: 'pg', text: 'q', count: 9, scans: [scan({ tableRows: 500 })] }])).toEqual([]);
451+
expect(indexFindings([{ driver: 'pg', text: 'q', count: 9, scans: [scan({ rows: 30000 })] }])).toEqual([]);
452+
expect(indexFindings([{ driver: 'pg', text: 'q', count: 9, scans: [scan({ columns: [] })] }])).toEqual([]);
453+
expect(indexFindings([{ driver: 'pg', text: 'q', count: 9, scans: [scan({ tableRows: 500 })] }], 100)).toHaveLength(1);
454+
});
455+
});

0 commit comments

Comments
 (0)