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.
@@ -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
+ });