pgserve 1.1.3 → 1.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +113 -19
- package/bin/pglite-server.js +58 -7
- package/package.json +1 -1
- package/src/cluster.js +81 -9
- package/src/index.js +2 -0
- package/src/postgres.js +1 -0
- package/src/stats-collector.js +267 -0
- package/src/stats-dashboard.js +382 -0
- package/tests/benchmarks/runner.js +871 -50
- package/tests/benchmarks/vector-generator.js +368 -0
- package/tests/quick-bench.js +135 -0
- package/tests/stress-test.js +439 -0
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* pgserve Stress Test Suite
|
|
5
|
+
*
|
|
6
|
+
* Like PassMark but for PostgreSQL - progressive load testing with multiple scenarios.
|
|
7
|
+
* Perfect for filming terminal under stress.
|
|
8
|
+
*
|
|
9
|
+
* Usage: bun tests/stress-test.js [port]
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import pg from 'pg';
|
|
13
|
+
const { Pool } = pg;
|
|
14
|
+
|
|
15
|
+
const PORT = parseInt(process.argv[2]) || 8433;
|
|
16
|
+
|
|
17
|
+
// ANSI colors
|
|
18
|
+
const C = {
|
|
19
|
+
reset: '\x1b[0m',
|
|
20
|
+
bold: '\x1b[1m',
|
|
21
|
+
dim: '\x1b[2m',
|
|
22
|
+
green: '\x1b[32m',
|
|
23
|
+
yellow: '\x1b[33m',
|
|
24
|
+
cyan: '\x1b[36m',
|
|
25
|
+
red: '\x1b[31m',
|
|
26
|
+
magenta: '\x1b[35m',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const results = [];
|
|
30
|
+
let globalPool = null;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Create a connection pool
|
|
34
|
+
*/
|
|
35
|
+
function createPool(maxConnections) {
|
|
36
|
+
return new Pool({
|
|
37
|
+
host: '127.0.0.1',
|
|
38
|
+
port: PORT,
|
|
39
|
+
database: 'stresstest',
|
|
40
|
+
user: 'postgres',
|
|
41
|
+
password: 'postgres',
|
|
42
|
+
max: maxConnections,
|
|
43
|
+
idleTimeoutMillis: 30000,
|
|
44
|
+
connectionTimeoutMillis: 10000,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Setup test tables
|
|
50
|
+
*/
|
|
51
|
+
async function setup(pool) {
|
|
52
|
+
const client = await pool.connect();
|
|
53
|
+
try {
|
|
54
|
+
await client.query(`
|
|
55
|
+
DROP TABLE IF EXISTS stress_users CASCADE;
|
|
56
|
+
DROP TABLE IF EXISTS stress_orders CASCADE;
|
|
57
|
+
DROP TABLE IF EXISTS stress_logs CASCADE;
|
|
58
|
+
|
|
59
|
+
CREATE TABLE stress_users (
|
|
60
|
+
id SERIAL PRIMARY KEY,
|
|
61
|
+
name TEXT NOT NULL,
|
|
62
|
+
email TEXT,
|
|
63
|
+
created_at TIMESTAMP DEFAULT NOW()
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
CREATE TABLE stress_orders (
|
|
67
|
+
id SERIAL PRIMARY KEY,
|
|
68
|
+
user_id INTEGER,
|
|
69
|
+
amount DECIMAL(10,2),
|
|
70
|
+
status TEXT DEFAULT 'pending',
|
|
71
|
+
created_at TIMESTAMP DEFAULT NOW()
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
CREATE TABLE stress_logs (
|
|
75
|
+
id SERIAL PRIMARY KEY,
|
|
76
|
+
level TEXT,
|
|
77
|
+
message TEXT,
|
|
78
|
+
data JSONB,
|
|
79
|
+
created_at TIMESTAMP DEFAULT NOW()
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
CREATE INDEX idx_orders_user ON stress_orders(user_id);
|
|
83
|
+
CREATE INDEX idx_orders_status ON stress_orders(status);
|
|
84
|
+
CREATE INDEX idx_logs_level ON stress_logs(level);
|
|
85
|
+
`);
|
|
86
|
+
} finally {
|
|
87
|
+
client.release();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Print banner
|
|
93
|
+
*/
|
|
94
|
+
function banner() {
|
|
95
|
+
console.log(`
|
|
96
|
+
${C.cyan}${C.bold}╔════════════════════════════════════════════════════════════════╗
|
|
97
|
+
║ pgserve STRESS TEST SUITE ║
|
|
98
|
+
║ ║
|
|
99
|
+
║ Progressive load testing with multiple scenarios ║
|
|
100
|
+
╚════════════════════════════════════════════════════════════════╝${C.reset}
|
|
101
|
+
|
|
102
|
+
${C.dim}Target: postgresql://127.0.0.1:${PORT}/stresstest${C.reset}
|
|
103
|
+
`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Print section header
|
|
108
|
+
*/
|
|
109
|
+
function section(name, description) {
|
|
110
|
+
console.log(`
|
|
111
|
+
${C.yellow}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${C.reset}
|
|
112
|
+
${C.bold}${C.cyan}▶ ${name}${C.reset}
|
|
113
|
+
${C.dim}${description}${C.reset}
|
|
114
|
+
${C.yellow}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${C.reset}
|
|
115
|
+
`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Progress bar
|
|
120
|
+
*/
|
|
121
|
+
function progressBar(current, total, width = 30) {
|
|
122
|
+
const pct = Math.min(1, Math.max(0, current / total || 0));
|
|
123
|
+
// filled is clamped to [0, width], so (width - filled) is always non-negative
|
|
124
|
+
const filled = Math.max(0, Math.min(width, Math.round(pct * width)));
|
|
125
|
+
const empty = width - filled;
|
|
126
|
+
return `[${'█'.repeat(filled)}${'░'.repeat(empty)}] ${(pct * 100).toFixed(0)}%`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Run a test phase
|
|
131
|
+
*/
|
|
132
|
+
async function runPhase(name, config) {
|
|
133
|
+
const { connections, duration, workload } = config;
|
|
134
|
+
|
|
135
|
+
const pool = createPool(connections);
|
|
136
|
+
const latencies = [];
|
|
137
|
+
let queries = 0;
|
|
138
|
+
let errors = 0;
|
|
139
|
+
let running = true;
|
|
140
|
+
|
|
141
|
+
const startTime = performance.now();
|
|
142
|
+
|
|
143
|
+
// Worker function
|
|
144
|
+
async function worker(id) {
|
|
145
|
+
while (running) {
|
|
146
|
+
const start = performance.now();
|
|
147
|
+
try {
|
|
148
|
+
await workload(pool, id);
|
|
149
|
+
latencies.push(performance.now() - start);
|
|
150
|
+
queries++;
|
|
151
|
+
} catch (err) {
|
|
152
|
+
// Only count errors while test is running (not pool shutdown errors)
|
|
153
|
+
if (running) {
|
|
154
|
+
errors++;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Start workers
|
|
161
|
+
const workers = Array.from({ length: connections }, (_, i) => worker(i));
|
|
162
|
+
|
|
163
|
+
// Progress display
|
|
164
|
+
const progressInterval = setInterval(() => {
|
|
165
|
+
const elapsed = (performance.now() - startTime) / 1000;
|
|
166
|
+
const qps = queries / elapsed;
|
|
167
|
+
const progress = progressBar(elapsed, duration);
|
|
168
|
+
process.stdout.write(`\r ${progress} | ${queries.toLocaleString()} queries | ${qps.toFixed(0)} QPS | ${errors} errors `);
|
|
169
|
+
}, 200);
|
|
170
|
+
|
|
171
|
+
// Wait for duration
|
|
172
|
+
await new Promise(r => setTimeout(r, duration * 1000));
|
|
173
|
+
running = false;
|
|
174
|
+
|
|
175
|
+
// End pool immediately to unblock workers waiting for connections
|
|
176
|
+
// This is necessary because workers may be stuck in pool.connect() or pool.query()
|
|
177
|
+
await pool.end().catch(() => {});
|
|
178
|
+
|
|
179
|
+
// Wait for workers to finish (with timeout in case any are still stuck)
|
|
180
|
+
await Promise.race([
|
|
181
|
+
Promise.allSettled(workers),
|
|
182
|
+
new Promise(r => setTimeout(r, 2000)) // 2 second grace period
|
|
183
|
+
]);
|
|
184
|
+
clearInterval(progressInterval);
|
|
185
|
+
|
|
186
|
+
const totalTime = (performance.now() - startTime) / 1000;
|
|
187
|
+
|
|
188
|
+
// Calculate stats
|
|
189
|
+
latencies.sort((a, b) => a - b);
|
|
190
|
+
const avg = latencies.length > 0 ? latencies.reduce((a, b) => a + b, 0) / latencies.length : 0;
|
|
191
|
+
const p50 = latencies[Math.floor(latencies.length * 0.5)] || 0;
|
|
192
|
+
const p95 = latencies[Math.floor(latencies.length * 0.95)] || 0;
|
|
193
|
+
const p99 = latencies[Math.floor(latencies.length * 0.99)] || 0;
|
|
194
|
+
const qps = queries / totalTime;
|
|
195
|
+
|
|
196
|
+
const result = {
|
|
197
|
+
name,
|
|
198
|
+
connections,
|
|
199
|
+
duration: totalTime,
|
|
200
|
+
queries,
|
|
201
|
+
errors,
|
|
202
|
+
qps,
|
|
203
|
+
latency: { avg, p50, p95, p99 }
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
results.push(result);
|
|
207
|
+
|
|
208
|
+
console.log(`\r ${C.green}✓${C.reset} Complete: ${queries.toLocaleString()} queries in ${totalTime.toFixed(1)}s = ${C.bold}${qps.toFixed(0)} QPS${C.reset} `);
|
|
209
|
+
console.log(` ${C.dim}Latency: avg=${avg.toFixed(1)}ms p50=${p50.toFixed(1)}ms p95=${p95.toFixed(1)}ms p99=${p99.toFixed(1)}ms${C.reset}`);
|
|
210
|
+
|
|
211
|
+
return result;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ============================================================================
|
|
215
|
+
// WORKLOADS
|
|
216
|
+
// ============================================================================
|
|
217
|
+
|
|
218
|
+
const workloads = {
|
|
219
|
+
// Pure inserts
|
|
220
|
+
writeHeavy: async (pool, workerId) => {
|
|
221
|
+
await pool.query(
|
|
222
|
+
'INSERT INTO stress_logs (level, message, data) VALUES ($1, $2, $3)',
|
|
223
|
+
['info', `Worker ${workerId} log entry`, JSON.stringify({ ts: Date.now(), worker: workerId })]
|
|
224
|
+
);
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
// Pure reads
|
|
228
|
+
readHeavy: async (pool) => {
|
|
229
|
+
await pool.query('SELECT * FROM stress_logs ORDER BY id DESC LIMIT 50');
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
// Mixed CRUD
|
|
233
|
+
mixed: async (pool, workerId) => {
|
|
234
|
+
const op = Math.random();
|
|
235
|
+
if (op < 0.3) {
|
|
236
|
+
// 30% writes
|
|
237
|
+
await pool.query(
|
|
238
|
+
'INSERT INTO stress_orders (user_id, amount, status) VALUES ($1, $2, $3)',
|
|
239
|
+
[Math.floor(Math.random() * 1000), Math.random() * 1000, 'pending']
|
|
240
|
+
);
|
|
241
|
+
} else if (op < 0.5) {
|
|
242
|
+
// 20% updates
|
|
243
|
+
await pool.query(
|
|
244
|
+
"UPDATE stress_orders SET status = $1 WHERE id = (SELECT id FROM stress_orders WHERE status = 'pending' LIMIT 1)",
|
|
245
|
+
['completed']
|
|
246
|
+
);
|
|
247
|
+
} else {
|
|
248
|
+
// 50% reads
|
|
249
|
+
await pool.query('SELECT * FROM stress_orders WHERE status = $1 LIMIT 20', ['pending']);
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
|
|
253
|
+
// Complex queries with joins
|
|
254
|
+
complex: async (pool) => {
|
|
255
|
+
await pool.query(`
|
|
256
|
+
SELECT o.*, COUNT(*) OVER() as total
|
|
257
|
+
FROM stress_orders o
|
|
258
|
+
WHERE o.created_at > NOW() - INTERVAL '1 hour'
|
|
259
|
+
ORDER BY o.created_at DESC
|
|
260
|
+
LIMIT 10
|
|
261
|
+
`);
|
|
262
|
+
},
|
|
263
|
+
|
|
264
|
+
// Transaction heavy
|
|
265
|
+
transactions: async (pool) => {
|
|
266
|
+
const client = await pool.connect();
|
|
267
|
+
try {
|
|
268
|
+
await client.query('BEGIN');
|
|
269
|
+
await client.query(
|
|
270
|
+
'INSERT INTO stress_users (name, email) VALUES ($1, $2) RETURNING id',
|
|
271
|
+
[`User-${Date.now()}`, `user-${Date.now()}@test.com`]
|
|
272
|
+
);
|
|
273
|
+
await client.query(
|
|
274
|
+
'INSERT INTO stress_orders (user_id, amount) VALUES (currval(pg_get_serial_sequence(\'stress_users\', \'id\')), $1)',
|
|
275
|
+
[Math.random() * 500]
|
|
276
|
+
);
|
|
277
|
+
await client.query('COMMIT');
|
|
278
|
+
} catch (err) {
|
|
279
|
+
await client.query('ROLLBACK');
|
|
280
|
+
throw err;
|
|
281
|
+
} finally {
|
|
282
|
+
client.release();
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
// ============================================================================
|
|
288
|
+
// MAIN TEST SUITE
|
|
289
|
+
// ============================================================================
|
|
290
|
+
|
|
291
|
+
async function runSuite() {
|
|
292
|
+
banner();
|
|
293
|
+
|
|
294
|
+
console.log(`${C.dim}Connecting and setting up test database...${C.reset}`);
|
|
295
|
+
globalPool = createPool(5);
|
|
296
|
+
await setup(globalPool);
|
|
297
|
+
console.log(`${C.green}✓${C.reset} Setup complete\n`);
|
|
298
|
+
|
|
299
|
+
// -------------------------------------------------------------------------
|
|
300
|
+
// TEST 1: Connection Ramp-Up
|
|
301
|
+
// -------------------------------------------------------------------------
|
|
302
|
+
section('TEST 1: Connection Ramp-Up', 'Gradually increasing concurrent connections');
|
|
303
|
+
|
|
304
|
+
for (const conns of [10, 50, 100, 250, 500]) {
|
|
305
|
+
console.log(`\n ${C.cyan}→ ${conns} connections${C.reset}`);
|
|
306
|
+
await runPhase(`ramp-${conns}`, {
|
|
307
|
+
connections: conns,
|
|
308
|
+
duration: 10,
|
|
309
|
+
workload: workloads.mixed
|
|
310
|
+
});
|
|
311
|
+
await new Promise(r => setTimeout(r, 1000)); // Brief pause between phases
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// -------------------------------------------------------------------------
|
|
315
|
+
// TEST 2: Write Stress
|
|
316
|
+
// -------------------------------------------------------------------------
|
|
317
|
+
section('TEST 2: Write Stress', 'Heavy INSERT workload - 200 connections, 15 seconds');
|
|
318
|
+
|
|
319
|
+
await runPhase('write-stress', {
|
|
320
|
+
connections: 200,
|
|
321
|
+
duration: 15,
|
|
322
|
+
workload: workloads.writeHeavy
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// -------------------------------------------------------------------------
|
|
326
|
+
// TEST 3: Read Stress
|
|
327
|
+
// -------------------------------------------------------------------------
|
|
328
|
+
section('TEST 3: Read Stress', 'Heavy SELECT workload - 200 connections, 15 seconds');
|
|
329
|
+
|
|
330
|
+
await runPhase('read-stress', {
|
|
331
|
+
connections: 200,
|
|
332
|
+
duration: 15,
|
|
333
|
+
workload: workloads.readHeavy
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// -------------------------------------------------------------------------
|
|
337
|
+
// TEST 4: Mixed Workload
|
|
338
|
+
// -------------------------------------------------------------------------
|
|
339
|
+
section('TEST 4: Mixed Workload', 'Real-world CRUD simulation - 300 connections, 20 seconds');
|
|
340
|
+
|
|
341
|
+
await runPhase('mixed-heavy', {
|
|
342
|
+
connections: 300,
|
|
343
|
+
duration: 20,
|
|
344
|
+
workload: workloads.mixed
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// -------------------------------------------------------------------------
|
|
348
|
+
// TEST 5: Transaction Stress
|
|
349
|
+
// -------------------------------------------------------------------------
|
|
350
|
+
section('TEST 5: Transaction Stress', 'Multi-statement transactions - 150 connections, 15 seconds');
|
|
351
|
+
|
|
352
|
+
await runPhase('transactions', {
|
|
353
|
+
connections: 150,
|
|
354
|
+
duration: 15,
|
|
355
|
+
workload: workloads.transactions
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// -------------------------------------------------------------------------
|
|
359
|
+
// TEST 6: Peak Load
|
|
360
|
+
// -------------------------------------------------------------------------
|
|
361
|
+
section('TEST 6: Peak Load', 'Maximum stress - 500 connections, 20 seconds');
|
|
362
|
+
|
|
363
|
+
await runPhase('peak-load', {
|
|
364
|
+
connections: 500,
|
|
365
|
+
duration: 20,
|
|
366
|
+
workload: workloads.mixed
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// -------------------------------------------------------------------------
|
|
370
|
+
// TEST 7: Extreme Load
|
|
371
|
+
// -------------------------------------------------------------------------
|
|
372
|
+
section('TEST 7: Extreme Load', 'Near-limit stress - 750 connections, 15 seconds');
|
|
373
|
+
|
|
374
|
+
await runPhase('extreme-load', {
|
|
375
|
+
connections: 750,
|
|
376
|
+
duration: 15,
|
|
377
|
+
workload: workloads.mixed
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// -------------------------------------------------------------------------
|
|
381
|
+
// FINAL REPORT
|
|
382
|
+
// -------------------------------------------------------------------------
|
|
383
|
+
await globalPool.end();
|
|
384
|
+
|
|
385
|
+
console.log(`
|
|
386
|
+
${C.cyan}${C.bold}
|
|
387
|
+
╔════════════════════════════════════════════════════════════════╗
|
|
388
|
+
║ FINAL RESULTS ║
|
|
389
|
+
╚════════════════════════════════════════════════════════════════╝${C.reset}
|
|
390
|
+
`);
|
|
391
|
+
|
|
392
|
+
// Summary table
|
|
393
|
+
console.log(`${C.bold}Test Name Conn Queries QPS Avg P95 P99 Errors${C.reset}`);
|
|
394
|
+
console.log(`${'─'.repeat(85)}`);
|
|
395
|
+
|
|
396
|
+
let totalQueries = 0;
|
|
397
|
+
let totalErrors = 0;
|
|
398
|
+
let peakQps = 0;
|
|
399
|
+
|
|
400
|
+
for (const r of results) {
|
|
401
|
+
const name = r.name.padEnd(20);
|
|
402
|
+
const conn = String(r.connections).padStart(4);
|
|
403
|
+
const queries = r.queries.toLocaleString().padStart(10);
|
|
404
|
+
const qps = r.qps.toFixed(0).padStart(7);
|
|
405
|
+
const avg = r.latency.avg.toFixed(1).padStart(6) + 'ms';
|
|
406
|
+
const p95 = r.latency.p95.toFixed(1).padStart(6) + 'ms';
|
|
407
|
+
const p99 = r.latency.p99.toFixed(1).padStart(6) + 'ms';
|
|
408
|
+
const errors = String(r.errors).padStart(6);
|
|
409
|
+
|
|
410
|
+
const color = r.errors > 0 ? C.yellow : C.green;
|
|
411
|
+
console.log(`${color}${name} ${conn} ${queries} ${qps} ${avg} ${p95} ${p99} ${errors}${C.reset}`);
|
|
412
|
+
|
|
413
|
+
totalQueries += r.queries;
|
|
414
|
+
totalErrors += r.errors;
|
|
415
|
+
if (r.qps > peakQps) peakQps = r.qps;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
console.log(`${'─'.repeat(85)}`);
|
|
419
|
+
|
|
420
|
+
// Score calculation (arbitrary but fun)
|
|
421
|
+
const score = Math.round((peakQps * 0.5) + (totalQueries / 1000) - (totalErrors * 10));
|
|
422
|
+
|
|
423
|
+
console.log(`
|
|
424
|
+
${C.bold}Summary:${C.reset}
|
|
425
|
+
Total Queries: ${totalQueries.toLocaleString()}
|
|
426
|
+
Total Errors: ${totalErrors}
|
|
427
|
+
Peak QPS: ${peakQps.toFixed(0)}
|
|
428
|
+
|
|
429
|
+
${C.magenta}${C.bold}╔═══════════════════════════════════╗
|
|
430
|
+
║ PGSERVE SCORE: ${String(score).padStart(6)} ║
|
|
431
|
+
╚═══════════════════════════════════╝${C.reset}
|
|
432
|
+
`);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Run
|
|
436
|
+
runSuite().catch(err => {
|
|
437
|
+
console.error(`${C.red}Error:${C.reset}`, err);
|
|
438
|
+
process.exit(1);
|
|
439
|
+
});
|