portok 1.0.0
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/.dockerignore +10 -0
- package/Dockerfile +41 -0
- package/README.md +606 -0
- package/bench/baseline.bench.mjs +73 -0
- package/bench/connections.bench.mjs +70 -0
- package/bench/keepalive.bench.mjs +248 -0
- package/bench/latency.bench.mjs +47 -0
- package/bench/run.mjs +211 -0
- package/bench/switching.bench.mjs +96 -0
- package/bench/throughput.bench.mjs +44 -0
- package/bench/validate.mjs +260 -0
- package/docker-compose.yml +62 -0
- package/examples/api.env +30 -0
- package/examples/web.env +27 -0
- package/package.json +39 -0
- package/portok.mjs +793 -0
- package/portok@.service +62 -0
- package/portokd.mjs +793 -0
- package/test/cli.test.mjs +220 -0
- package/test/drain.test.mjs +249 -0
- package/test/helpers/mock-server.mjs +305 -0
- package/test/metrics.test.mjs +328 -0
- package/test/proxy.test.mjs +223 -0
- package/test/rollback.test.mjs +344 -0
- package/test/security.test.mjs +256 -0
- package/test/switching.test.mjs +261 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Baseline Benchmark
|
|
3
|
+
* Compares direct server performance vs proxied performance
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import autocannon from 'autocannon';
|
|
7
|
+
import { createMockServer, startDaemon, getFreePort, formatNumber } from './run.mjs';
|
|
8
|
+
|
|
9
|
+
export async function run({ duration, adminToken }) {
|
|
10
|
+
const shortDuration = Math.max(2, Math.floor(duration / 2));
|
|
11
|
+
|
|
12
|
+
// Setup mock server for direct test
|
|
13
|
+
const mockServer = await createMockServer();
|
|
14
|
+
|
|
15
|
+
// Test 1: Direct to mock server
|
|
16
|
+
const directResult = await autocannon({
|
|
17
|
+
url: `http://127.0.0.1:${mockServer.port}/`,
|
|
18
|
+
connections: 100,
|
|
19
|
+
pipelining: 10,
|
|
20
|
+
duration: shortDuration,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const directRps = Math.round(directResult.requests.average);
|
|
24
|
+
const directLatency = directResult.latency.average;
|
|
25
|
+
|
|
26
|
+
// Setup proxy
|
|
27
|
+
const proxyPort = await getFreePort();
|
|
28
|
+
const daemon = await startDaemon(proxyPort, mockServer.port);
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
// Test 2: Through proxy
|
|
32
|
+
const proxiedResult = await autocannon({
|
|
33
|
+
url: `http://127.0.0.1:${proxyPort}/`,
|
|
34
|
+
connections: 100,
|
|
35
|
+
pipelining: 10,
|
|
36
|
+
duration: shortDuration,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const proxiedRps = Math.round(proxiedResult.requests.average);
|
|
40
|
+
const proxiedLatency = proxiedResult.latency.average;
|
|
41
|
+
|
|
42
|
+
// Calculate overhead
|
|
43
|
+
const rpsOverhead = ((directRps - proxiedRps) / directRps * 100).toFixed(1);
|
|
44
|
+
const latencyOverhead = ((proxiedLatency - directLatency) / directLatency * 100).toFixed(1);
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
direct: {
|
|
48
|
+
rps: directRps,
|
|
49
|
+
latencyMs: directLatency,
|
|
50
|
+
},
|
|
51
|
+
proxied: {
|
|
52
|
+
rps: proxiedRps,
|
|
53
|
+
latencyMs: proxiedLatency,
|
|
54
|
+
},
|
|
55
|
+
overhead: {
|
|
56
|
+
rpsPercent: parseFloat(rpsOverhead),
|
|
57
|
+
latencyPercent: parseFloat(latencyOverhead),
|
|
58
|
+
},
|
|
59
|
+
display: {
|
|
60
|
+
'Direct RPS': formatNumber(directRps),
|
|
61
|
+
'Proxied RPS': formatNumber(proxiedRps),
|
|
62
|
+
'RPS Overhead': `${rpsOverhead}%`,
|
|
63
|
+
'Direct Latency': `${directLatency.toFixed(2)}ms`,
|
|
64
|
+
'Proxied Latency': `${proxiedLatency.toFixed(2)}ms`,
|
|
65
|
+
'Latency Overhead': `${latencyOverhead}%`,
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
} finally {
|
|
69
|
+
daemon.kill('SIGTERM');
|
|
70
|
+
await mockServer.close();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connections Benchmark
|
|
3
|
+
* Tests scaling with increasing concurrent connections
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import autocannon from 'autocannon';
|
|
7
|
+
import { createMockServer, startDaemon, getFreePort, formatNumber } from './run.mjs';
|
|
8
|
+
|
|
9
|
+
export async function run({ duration, adminToken }) {
|
|
10
|
+
// Setup
|
|
11
|
+
const mockServer = await createMockServer();
|
|
12
|
+
const proxyPort = await getFreePort();
|
|
13
|
+
const daemon = await startDaemon(proxyPort, mockServer.port);
|
|
14
|
+
|
|
15
|
+
const connectionLevels = [10, 50, 100, 250, 500];
|
|
16
|
+
const results = {};
|
|
17
|
+
const shortDuration = Math.max(2, Math.floor(duration / connectionLevels.length));
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
for (const connections of connectionLevels) {
|
|
21
|
+
const result = await autocannon({
|
|
22
|
+
url: `http://127.0.0.1:${proxyPort}/`,
|
|
23
|
+
connections,
|
|
24
|
+
pipelining: 1,
|
|
25
|
+
duration: shortDuration,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
results[connections] = {
|
|
29
|
+
rps: Math.round(result.requests.average),
|
|
30
|
+
latencyAvg: result.latency.average,
|
|
31
|
+
latencyP99: result.latency.p99,
|
|
32
|
+
errors: result.errors,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Get memory usage from metrics
|
|
37
|
+
let memoryMB = 0;
|
|
38
|
+
try {
|
|
39
|
+
const metricsRes = await fetch(`http://127.0.0.1:${proxyPort}/__metrics`, {
|
|
40
|
+
headers: { 'x-admin-token': adminToken },
|
|
41
|
+
});
|
|
42
|
+
if (metricsRes.ok) {
|
|
43
|
+
const metrics = await metricsRes.json();
|
|
44
|
+
memoryMB = metrics.memoryMB || process.memoryUsage().heapUsed / 1024 / 1024;
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
memoryMB = process.memoryUsage().heapUsed / 1024 / 1024;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Build display object
|
|
51
|
+
const display = {};
|
|
52
|
+
for (const [conn, data] of Object.entries(results)) {
|
|
53
|
+
display[`${conn} conn`] = `${formatNumber(data.rps)} rps, p99=${data.latencyP99}ms`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
levels: results,
|
|
58
|
+
memoryMB: memoryMB.toFixed(1),
|
|
59
|
+
peak: {
|
|
60
|
+
connections: Object.entries(results).reduce((a, b) => results[a]?.rps > results[b[0]]?.rps ? a : b[0], '10'),
|
|
61
|
+
rps: Math.max(...Object.values(results).map(r => r.rps)),
|
|
62
|
+
},
|
|
63
|
+
display,
|
|
64
|
+
};
|
|
65
|
+
} finally {
|
|
66
|
+
daemon.kill('SIGTERM');
|
|
67
|
+
await mockServer.close();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keep-Alive Benchmark
|
|
3
|
+
* Validates that proxy overhead is minimal with keep-alive enabled.
|
|
4
|
+
*
|
|
5
|
+
* Expected results:
|
|
6
|
+
* - Keep-alive should be at least 50% faster than no-keepalive
|
|
7
|
+
* - Proxied RPS should be at least 20% of direct RPS
|
|
8
|
+
*
|
|
9
|
+
* Can be run standalone or via bench/run.mjs:
|
|
10
|
+
* node bench/keepalive.bench.mjs --quick
|
|
11
|
+
* npm run bench
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import autocannon from 'autocannon';
|
|
15
|
+
import { spawn } from 'node:child_process';
|
|
16
|
+
import http from 'node:http';
|
|
17
|
+
|
|
18
|
+
// =============================================================================
|
|
19
|
+
// Utilities (used when running standalone)
|
|
20
|
+
// =============================================================================
|
|
21
|
+
|
|
22
|
+
function formatNumber(n) {
|
|
23
|
+
return n.toLocaleString('en-US', { maximumFractionDigits: 2 });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function getFreePort() {
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
const server = http.createServer();
|
|
29
|
+
server.listen(0, '127.0.0.1', () => {
|
|
30
|
+
const port = server.address().port;
|
|
31
|
+
server.close(() => resolve(port));
|
|
32
|
+
});
|
|
33
|
+
server.on('error', reject);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function waitFor(condition, timeout = 10000) {
|
|
38
|
+
const start = Date.now();
|
|
39
|
+
while (Date.now() - start < timeout) {
|
|
40
|
+
if (await condition()) return true;
|
|
41
|
+
await new Promise(r => setTimeout(r, 100));
|
|
42
|
+
}
|
|
43
|
+
throw new Error('Timeout waiting for condition');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function createMockServer(port = 0) {
|
|
47
|
+
const server = http.createServer((req, res) => {
|
|
48
|
+
if (req.url === '/health') {
|
|
49
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
50
|
+
res.end('{"status":"healthy"}');
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
54
|
+
res.end('OK');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
await new Promise(resolve => server.listen(port, '127.0.0.1', resolve));
|
|
58
|
+
return {
|
|
59
|
+
port: server.address().port,
|
|
60
|
+
close: () => new Promise(resolve => server.close(resolve)),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function startDaemonWithKA(listenPort, targetPort, adminToken, keepAlive = true) {
|
|
65
|
+
const proc = spawn('node', ['portokd.mjs'], {
|
|
66
|
+
env: {
|
|
67
|
+
...process.env,
|
|
68
|
+
LISTEN_PORT: String(listenPort),
|
|
69
|
+
INITIAL_TARGET_PORT: String(targetPort),
|
|
70
|
+
ADMIN_TOKEN: adminToken,
|
|
71
|
+
STATE_FILE: `/tmp/portok-keepalive-bench-${Date.now()}-${Math.random()}.json`,
|
|
72
|
+
DRAIN_MS: '1000',
|
|
73
|
+
ROLLBACK_WINDOW_MS: '60000',
|
|
74
|
+
ROLLBACK_CHECK_EVERY_MS: '5000',
|
|
75
|
+
ROLLBACK_FAIL_THRESHOLD: '3',
|
|
76
|
+
// Keep-alive settings
|
|
77
|
+
UPSTREAM_KEEPALIVE: keepAlive ? '1' : '0',
|
|
78
|
+
UPSTREAM_MAX_SOCKETS: '1024',
|
|
79
|
+
UPSTREAM_KEEPALIVE_MSECS: '2000',
|
|
80
|
+
// Benchmark mode
|
|
81
|
+
DISABLE_ADMIN_METRICS_IN_HOT_PATH: '1',
|
|
82
|
+
VERBOSE_ERRORS: '0',
|
|
83
|
+
},
|
|
84
|
+
stdio: 'pipe',
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
await waitFor(async () => {
|
|
88
|
+
try {
|
|
89
|
+
const res = await fetch(`http://127.0.0.1:${listenPort}/__status`, {
|
|
90
|
+
headers: { 'x-admin-token': adminToken },
|
|
91
|
+
});
|
|
92
|
+
return res.ok;
|
|
93
|
+
} catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
return proc;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// =============================================================================
|
|
102
|
+
// Main run function (called by bench/run.mjs)
|
|
103
|
+
// =============================================================================
|
|
104
|
+
|
|
105
|
+
export async function run({ duration, adminToken }) {
|
|
106
|
+
const shortDuration = Math.max(2, Math.floor(duration / 2));
|
|
107
|
+
const CONNECTIONS = 100;
|
|
108
|
+
const PIPELINING = 10;
|
|
109
|
+
|
|
110
|
+
// Setup mock server
|
|
111
|
+
const mockServer = await createMockServer();
|
|
112
|
+
|
|
113
|
+
// Test 1: Direct to mock server (baseline)
|
|
114
|
+
const directResult = await autocannon({
|
|
115
|
+
url: `http://127.0.0.1:${mockServer.port}/`,
|
|
116
|
+
connections: CONNECTIONS,
|
|
117
|
+
pipelining: PIPELINING,
|
|
118
|
+
duration: shortDuration,
|
|
119
|
+
});
|
|
120
|
+
const directRps = Math.round(directResult.requests.average);
|
|
121
|
+
const directLatency = directResult.latency.average;
|
|
122
|
+
|
|
123
|
+
// Test 2: Through proxy WITH keep-alive
|
|
124
|
+
const proxyPortKA = await getFreePort();
|
|
125
|
+
const daemonKA = await startDaemonWithKA(proxyPortKA, mockServer.port, adminToken, true);
|
|
126
|
+
|
|
127
|
+
const proxiedKAResult = await autocannon({
|
|
128
|
+
url: `http://127.0.0.1:${proxyPortKA}/`,
|
|
129
|
+
connections: CONNECTIONS,
|
|
130
|
+
pipelining: PIPELINING,
|
|
131
|
+
duration: shortDuration,
|
|
132
|
+
});
|
|
133
|
+
const proxiedKARps = Math.round(proxiedKAResult.requests.average);
|
|
134
|
+
const proxiedKALatency = proxiedKAResult.latency.average;
|
|
135
|
+
|
|
136
|
+
daemonKA.kill('SIGTERM');
|
|
137
|
+
|
|
138
|
+
// Test 3: Through proxy WITHOUT keep-alive
|
|
139
|
+
const proxyPortNoKA = await getFreePort();
|
|
140
|
+
const daemonNoKA = await startDaemonWithKA(proxyPortNoKA, mockServer.port, adminToken, false);
|
|
141
|
+
|
|
142
|
+
const proxiedNoKAResult = await autocannon({
|
|
143
|
+
url: `http://127.0.0.1:${proxyPortNoKA}/`,
|
|
144
|
+
connections: CONNECTIONS,
|
|
145
|
+
pipelining: PIPELINING,
|
|
146
|
+
duration: shortDuration,
|
|
147
|
+
});
|
|
148
|
+
const proxiedNoKARps = Math.round(proxiedNoKAResult.requests.average);
|
|
149
|
+
const proxiedNoKALatency = proxiedNoKAResult.latency.average;
|
|
150
|
+
|
|
151
|
+
daemonNoKA.kill('SIGTERM');
|
|
152
|
+
await mockServer.close();
|
|
153
|
+
|
|
154
|
+
// Calculate improvement
|
|
155
|
+
const kaImprovement = ((proxiedKARps - proxiedNoKARps) / proxiedNoKARps * 100);
|
|
156
|
+
const kaRpsOverhead = ((directRps - proxiedKARps) / directRps * 100);
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
direct: {
|
|
160
|
+
rps: directRps,
|
|
161
|
+
latencyMs: directLatency,
|
|
162
|
+
},
|
|
163
|
+
proxiedKeepAlive: {
|
|
164
|
+
rps: proxiedKARps,
|
|
165
|
+
latencyMs: proxiedKALatency,
|
|
166
|
+
},
|
|
167
|
+
proxiedNoKeepAlive: {
|
|
168
|
+
rps: proxiedNoKARps,
|
|
169
|
+
latencyMs: proxiedNoKALatency,
|
|
170
|
+
},
|
|
171
|
+
improvement: {
|
|
172
|
+
keepAliveVsNoKeepAlive: `${kaImprovement.toFixed(1)}%`,
|
|
173
|
+
rpsOverhead: `${kaRpsOverhead.toFixed(1)}%`,
|
|
174
|
+
},
|
|
175
|
+
display: {
|
|
176
|
+
'Direct RPS': formatNumber(directRps),
|
|
177
|
+
'Proxied (KA) RPS': formatNumber(proxiedKARps),
|
|
178
|
+
'Proxied (no KA) RPS': formatNumber(proxiedNoKARps),
|
|
179
|
+
'KA Improvement': `${kaImprovement.toFixed(1)}%`,
|
|
180
|
+
'KA Latency': `${proxiedKALatency.toFixed(2)}ms`,
|
|
181
|
+
'No-KA Latency': `${proxiedNoKALatency.toFixed(2)}ms`,
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// =============================================================================
|
|
187
|
+
// Standalone execution
|
|
188
|
+
// =============================================================================
|
|
189
|
+
|
|
190
|
+
const isMain = import.meta.url === `file://${process.argv[1]}`;
|
|
191
|
+
|
|
192
|
+
if (isMain) {
|
|
193
|
+
const args = process.argv.slice(2);
|
|
194
|
+
const isQuick = args.includes('--quick');
|
|
195
|
+
const isJson = args.includes('--json');
|
|
196
|
+
const duration = isQuick ? 3 : 10;
|
|
197
|
+
const adminToken = 'bench-token-keepalive';
|
|
198
|
+
|
|
199
|
+
function log(msg) {
|
|
200
|
+
if (!isJson) console.log(msg);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
log('');
|
|
204
|
+
log('╔══════════════════════════════════════════════════════════════╗');
|
|
205
|
+
log('║ KEEP-ALIVE BENCHMARK VALIDATION ║');
|
|
206
|
+
log('╠══════════════════════════════════════════════════════════════╣');
|
|
207
|
+
log(`║ Duration: ${duration}s, Connections: 100, Pipelining: 10`.padEnd(63) + '║');
|
|
208
|
+
log(`║ Node: ${process.version}`.padEnd(63) + '║');
|
|
209
|
+
log('╚══════════════════════════════════════════════════════════════╝');
|
|
210
|
+
log('');
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
const results = await run({ duration, adminToken });
|
|
214
|
+
|
|
215
|
+
// Validation
|
|
216
|
+
const rpsPass = results.proxiedKeepAlive.rps >= results.direct.rps * 0.20;
|
|
217
|
+
const kaEffective = results.proxiedKeepAlive.rps >= results.proxiedNoKeepAlive.rps * 1.5;
|
|
218
|
+
const latencyPass = results.proxiedKeepAlive.latencyMs < results.proxiedNoKeepAlive.latencyMs;
|
|
219
|
+
const allPassed = rpsPass && kaEffective && latencyPass;
|
|
220
|
+
|
|
221
|
+
if (isJson) {
|
|
222
|
+
console.log(JSON.stringify({ ...results, validation: { rpsPass, kaEffective, latencyPass, allPassed } }, null, 2));
|
|
223
|
+
} else {
|
|
224
|
+
log('╔══════════════════════════════════════════════════════════════╗');
|
|
225
|
+
log('║ RESULTS ║');
|
|
226
|
+
log('╠══════════════════════════════════════════════════════════════╣');
|
|
227
|
+
for (const [key, value] of Object.entries(results.display)) {
|
|
228
|
+
log(`║ ${key}:`.padEnd(28) + `${value}`.padStart(33) + '║');
|
|
229
|
+
}
|
|
230
|
+
log('╠══════════════════════════════════════════════════════════════╣');
|
|
231
|
+
log('║ VALIDATION: ║');
|
|
232
|
+
log(`║ RPS >= 20% of direct: ${rpsPass ? 'PASS ✓' : 'FAIL ✗'} ║`);
|
|
233
|
+
log(`║ KA >= 50% faster than no-KA: ${kaEffective ? 'PASS ✓' : 'FAIL ✗'} ║`);
|
|
234
|
+
log(`║ KA latency < no-KA: ${latencyPass ? 'PASS ✓' : 'FAIL ✗'} ║`);
|
|
235
|
+
log('╚══════════════════════════════════════════════════════════════╝');
|
|
236
|
+
|
|
237
|
+
if (!allPassed) {
|
|
238
|
+
log('\n⚠️ Some validations failed.');
|
|
239
|
+
process.exit(1);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
process.exit(0);
|
|
244
|
+
} catch (err) {
|
|
245
|
+
console.error('Benchmark failed:', err);
|
|
246
|
+
process.exit(1);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Latency Benchmark
|
|
3
|
+
* Measures latency distribution under moderate load
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import autocannon from 'autocannon';
|
|
7
|
+
import { createMockServer, startDaemon, getFreePort } from './run.mjs';
|
|
8
|
+
|
|
9
|
+
export async function run({ duration, adminToken }) {
|
|
10
|
+
// Setup
|
|
11
|
+
const mockServer = await createMockServer();
|
|
12
|
+
const proxyPort = await getFreePort();
|
|
13
|
+
const daemon = await startDaemon(proxyPort, mockServer.port);
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
// Run benchmark with moderate load to get accurate latency
|
|
17
|
+
const result = await autocannon({
|
|
18
|
+
url: `http://127.0.0.1:${proxyPort}/`,
|
|
19
|
+
connections: 50,
|
|
20
|
+
pipelining: 1, // No pipelining for accurate latency
|
|
21
|
+
duration,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
p50Ms: result.latency.p50,
|
|
26
|
+
p75Ms: result.latency.p75,
|
|
27
|
+
p90Ms: result.latency.p90,
|
|
28
|
+
p95Ms: result.latency.p95 || result.latency.p90,
|
|
29
|
+
p99Ms: result.latency.p99,
|
|
30
|
+
maxMs: result.latency.max,
|
|
31
|
+
avgMs: result.latency.average,
|
|
32
|
+
minMs: result.latency.min,
|
|
33
|
+
display: {
|
|
34
|
+
'p50': `${result.latency.p50}ms`,
|
|
35
|
+
'p75': `${result.latency.p75}ms`,
|
|
36
|
+
'p90': `${result.latency.p90}ms`,
|
|
37
|
+
'p99': `${result.latency.p99}ms`,
|
|
38
|
+
'max': `${result.latency.max}ms`,
|
|
39
|
+
'avg': `${result.latency.average.toFixed(2)}ms`,
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
} finally {
|
|
43
|
+
daemon.kill('SIGTERM');
|
|
44
|
+
await mockServer.close();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
package/bench/run.mjs
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Benchmark Runner for Portok
|
|
5
|
+
* Orchestrates all benchmarks and outputs formatted results
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import http from 'node:http';
|
|
10
|
+
|
|
11
|
+
// =============================================================================
|
|
12
|
+
// Configuration
|
|
13
|
+
// =============================================================================
|
|
14
|
+
|
|
15
|
+
const DURATION = parseInt(process.env.BENCH_DURATION || '10', 10);
|
|
16
|
+
const QUICK_DURATION = 3;
|
|
17
|
+
const ADMIN_TOKEN = process.env.ADMIN_TOKEN || 'bench-token-12345';
|
|
18
|
+
|
|
19
|
+
const args = process.argv.slice(2);
|
|
20
|
+
const isQuick = args.includes('--quick');
|
|
21
|
+
const isJson = args.includes('--json');
|
|
22
|
+
const duration = isQuick ? QUICK_DURATION : DURATION;
|
|
23
|
+
|
|
24
|
+
// =============================================================================
|
|
25
|
+
// Utilities
|
|
26
|
+
// =============================================================================
|
|
27
|
+
|
|
28
|
+
function log(msg) {
|
|
29
|
+
if (!isJson) {
|
|
30
|
+
console.log(msg);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function formatNumber(n) {
|
|
35
|
+
return n.toLocaleString('en-US', { maximumFractionDigits: 2 });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function getFreePort() {
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
const server = http.createServer();
|
|
41
|
+
server.listen(0, '127.0.0.1', () => {
|
|
42
|
+
const port = server.address().port;
|
|
43
|
+
server.close(() => resolve(port));
|
|
44
|
+
});
|
|
45
|
+
server.on('error', reject);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function waitFor(condition, timeout = 10000) {
|
|
50
|
+
const start = Date.now();
|
|
51
|
+
while (Date.now() - start < timeout) {
|
|
52
|
+
if (await condition()) return true;
|
|
53
|
+
await new Promise(r => setTimeout(r, 100));
|
|
54
|
+
}
|
|
55
|
+
throw new Error('Timeout waiting for condition');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// =============================================================================
|
|
59
|
+
// Mock Server
|
|
60
|
+
// =============================================================================
|
|
61
|
+
|
|
62
|
+
async function createMockServer(port = 0) {
|
|
63
|
+
const server = http.createServer((req, res) => {
|
|
64
|
+
if (req.url === '/health') {
|
|
65
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
66
|
+
res.end('{"status":"healthy"}');
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
70
|
+
res.end('OK');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
await new Promise(resolve => server.listen(port, '127.0.0.1', resolve));
|
|
74
|
+
return {
|
|
75
|
+
port: server.address().port,
|
|
76
|
+
close: () => new Promise(resolve => server.close(resolve)),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// =============================================================================
|
|
81
|
+
// Start Daemon
|
|
82
|
+
// =============================================================================
|
|
83
|
+
|
|
84
|
+
async function startDaemon(listenPort, targetPort) {
|
|
85
|
+
const proc = spawn('node', ['portokd.mjs'], {
|
|
86
|
+
env: {
|
|
87
|
+
...process.env,
|
|
88
|
+
LISTEN_PORT: String(listenPort),
|
|
89
|
+
INITIAL_TARGET_PORT: String(targetPort),
|
|
90
|
+
ADMIN_TOKEN,
|
|
91
|
+
STATE_FILE: `/tmp/portok-bench-${Date.now()}.json`,
|
|
92
|
+
DRAIN_MS: '1000',
|
|
93
|
+
ROLLBACK_WINDOW_MS: '60000',
|
|
94
|
+
ROLLBACK_CHECK_EVERY_MS: '5000',
|
|
95
|
+
ROLLBACK_FAIL_THRESHOLD: '3',
|
|
96
|
+
},
|
|
97
|
+
stdio: 'pipe',
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
await waitFor(async () => {
|
|
101
|
+
try {
|
|
102
|
+
const res = await fetch(`http://127.0.0.1:${listenPort}/__status`, {
|
|
103
|
+
headers: { 'x-admin-token': ADMIN_TOKEN },
|
|
104
|
+
});
|
|
105
|
+
return res.ok;
|
|
106
|
+
} catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
return proc;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// =============================================================================
|
|
115
|
+
// Run Individual Benchmarks
|
|
116
|
+
// =============================================================================
|
|
117
|
+
|
|
118
|
+
async function runBenchmark(file) {
|
|
119
|
+
const module = await import(file);
|
|
120
|
+
return module.run({ duration, adminToken: ADMIN_TOKEN });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// =============================================================================
|
|
124
|
+
// Output Formatting
|
|
125
|
+
// =============================================================================
|
|
126
|
+
|
|
127
|
+
function printBox(title, content) {
|
|
128
|
+
const width = 60;
|
|
129
|
+
const line = '═'.repeat(width);
|
|
130
|
+
|
|
131
|
+
log(`╔${line}╗`);
|
|
132
|
+
log(`║ ${title.padEnd(width - 1)}║`);
|
|
133
|
+
log(`╠${line}╣`);
|
|
134
|
+
|
|
135
|
+
for (const [key, value] of Object.entries(content)) {
|
|
136
|
+
const row = ` ${key}:`.padEnd(20) + String(value).padStart(width - 21);
|
|
137
|
+
log(`║${row}║`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
log(`╚${line}╝`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function printHeader() {
|
|
144
|
+
log('');
|
|
145
|
+
log('╔══════════════════════════════════════════════════════════════╗');
|
|
146
|
+
log('║ PORTOK BENCHMARK RESULTS ║');
|
|
147
|
+
log('╠══════════════════════════════════════════════════════════════╣');
|
|
148
|
+
log(`║ Duration: ${duration}s ${isQuick ? '(quick mode)' : ''}`.padEnd(63) + '║');
|
|
149
|
+
log(`║ Node: ${process.version}`.padEnd(63) + '║');
|
|
150
|
+
log(`║ Platform: ${process.platform} ${process.arch}`.padEnd(63) + '║');
|
|
151
|
+
log('╚══════════════════════════════════════════════════════════════╝');
|
|
152
|
+
log('');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// =============================================================================
|
|
156
|
+
// Main
|
|
157
|
+
// =============================================================================
|
|
158
|
+
|
|
159
|
+
async function main() {
|
|
160
|
+
const results = {};
|
|
161
|
+
|
|
162
|
+
if (!isJson) {
|
|
163
|
+
printHeader();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Run each benchmark
|
|
167
|
+
const benchmarks = [
|
|
168
|
+
{ name: 'Throughput', file: './throughput.bench.mjs' },
|
|
169
|
+
{ name: 'Latency', file: './latency.bench.mjs' },
|
|
170
|
+
{ name: 'Connections', file: './connections.bench.mjs' },
|
|
171
|
+
{ name: 'Switching', file: './switching.bench.mjs' },
|
|
172
|
+
{ name: 'Baseline', file: './baseline.bench.mjs' },
|
|
173
|
+
{ name: 'KeepAlive', file: './keepalive.bench.mjs' },
|
|
174
|
+
];
|
|
175
|
+
|
|
176
|
+
for (const bench of benchmarks) {
|
|
177
|
+
log(`Running ${bench.name} benchmark...`);
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
const result = await runBenchmark(bench.file);
|
|
181
|
+
results[bench.name.toLowerCase()] = result;
|
|
182
|
+
|
|
183
|
+
if (!isJson) {
|
|
184
|
+
printBox(`${bench.name} Test`, result.display || result);
|
|
185
|
+
log('');
|
|
186
|
+
}
|
|
187
|
+
} catch (err) {
|
|
188
|
+
log(` Error: ${err.message}`);
|
|
189
|
+
results[bench.name.toLowerCase()] = { error: err.message };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Output JSON if requested
|
|
194
|
+
if (isJson) {
|
|
195
|
+
console.log(JSON.stringify(results, null, 2));
|
|
196
|
+
} else {
|
|
197
|
+
log('╔══════════════════════════════════════════════════════════════╗');
|
|
198
|
+
log('║ BENCHMARK COMPLETE ║');
|
|
199
|
+
log('╚══════════════════════════════════════════════════════════════╝');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
process.exit(0);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
main().catch(err => {
|
|
206
|
+
console.error('Benchmark failed:', err);
|
|
207
|
+
process.exit(1);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
export { createMockServer, startDaemon, getFreePort, waitFor, formatNumber, ADMIN_TOKEN };
|
|
211
|
+
|