Skip to content

Commit a96d54d

Browse files
authored
feat: implement traffic-weighted stress test framework and scenario improvements (#4169)
Signed-off-by: Logan Nguyen <[email protected]>
1 parent c108933 commit a96d54d

File tree

14 files changed

+477
-41
lines changed

14 files changed

+477
-41
lines changed

.eslintrc.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ module.exports = {
1010
"plugin:@typescript-eslint/recommended",
1111
"prettier",
1212
],
13+
"globals": {
14+
"__ENV": "readonly",
15+
},
1316
"plugins": [
1417
"simple-import-sort",
1518
"header",

k6/.envexample

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ DEFAULT_GRACEFUL_STOP=
1010
FILTER_TEST=
1111
DEBUG_MODE=
1212
SIGNED_TXS=
13+
STRESS_TEST_TARGET_TOTAL_RPS=

k6/README.md

Lines changed: 99 additions & 17 deletions
Large diffs are not rendered by default.

k6/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
"prep-and-run": "npm run prep && npm run k6",
88
"prep": "env-cmd node src/prepare/prep.js",
99
"k6": "env-cmd --use-shell k6 run src/scenarios/apis.js",
10-
"k6-ws": "env-cmd --use-shell k6 run src/scenarios/ws-apis.js"
10+
"k6-ws": "env-cmd --use-shell k6 run src/scenarios/ws-apis.js",
11+
"stress-test": "TEST_TYPE=stress env-cmd --use-shell k6 run src/scenarios/stress-test.js",
12+
"prep-and-stress": "npm run prep && npm run stress-test"
1113
},
1214
"dependencies": {
1315
"env-cmd": "^10.1.0",

k6/src/lib/common.js

Lines changed: 121 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
// SPDX-License-Identifier: Apache-2.0
22

3+
import { randomIntBetween } from 'https://jslib.k6.io/k6-utils/1.4.0/index.js';
34
import { check, sleep } from 'k6';
45
import { Gauge } from 'k6/metrics';
5-
import { randomIntBetween } from 'https://jslib.k6.io/k6-utils/1.4.0/index.js';
66

77
import { setDefaultValuesForEnvParameters } from './parameters.js';
8+
import { calculateRateAllocation } from './traffic-weights.js';
89

910
setDefaultValuesForEnvParameters();
1011

@@ -178,20 +179,24 @@ function defaultMetrics() {
178179
}
179180

180181
function getTestType() {
181-
return __ENV.TEST_TYPE !== undefined && __ENV.TEST_TYPE === 'load' ? 'load' : 'performance';
182+
if (__ENV.TEST_TYPE === 'stress') return 'stress';
183+
if (__ENV.TEST_TYPE === 'load') return 'load';
184+
return 'performance';
182185
}
183186

184187
function markdownReport(data, isFirstColumnUrl, scenarios) {
188+
const testType = getTestType();
185189
const firstColumnName = isFirstColumnUrl ? 'URL' : 'Scenario';
186-
const header = `| ${firstColumnName} | VUS | Reqs | Pass % | RPS (1/s) | Pass RPS (1/s) | Avg. Req Duration (ms) | Median (ms) | Min (ms) | Max (ms) | P(90) (ms) | P(95) (ms) | Comment |
190+
const secondColumnName = getTestType() === 'stress' ? 'Expected RPS' : 'VUS';
191+
const header = `| ${firstColumnName} | ${secondColumnName} | Reqs | Pass % | RPS (1/s) | Pass RPS (1/s) | Avg. Req Duration (ms) | Median (ms) | Min (ms) | Max (ms) | P(90) (ms) | P(95) (ms) | Comment |
187192
|----------|-----|------|--------|-----|----------|-------------------|-------|-----|-----|-------|-------|---------|`;
188193

189194
// collect the metrics
190195
const { metrics } = data;
191196

192197
const isDebugMode = __ENV['DEBUG_MODE'] === 'true';
193198
if (isDebugMode) {
194-
console.log("Raw metrics:");
199+
console.log('Raw metrics:');
195200
console.log(JSON.stringify(metrics, null, 2));
196201
}
197202

@@ -228,8 +233,12 @@ function markdownReport(data, isFirstColumnUrl, scenarios) {
228233
markdown += `JSON-RPC-RELAY URL: ${__ENV['RELAY_BASE_URL']}\n\n`;
229234
markdown += `Timestamp: ${new Date(Date.now()).toISOString()} \n\n`;
230235
markdown += `Duration: ${__ENV['DEFAULT_DURATION']} \n\n`;
231-
markdown += `Test Type: ${getTestType()} \n\n`;
232-
markdown += `Virtual Users (VUs): ${__ENV['DEFAULT_VUS']} \n\n`;
236+
markdown += `Test Type: ${testType} \n\n`;
237+
if (testType === 'stress') {
238+
markdown += `Target Total RPS: ${__ENV['STRESS_TEST_TARGET_TOTAL_RPS'] || 'N/A'} \n\n`;
239+
} else {
240+
markdown += `Virtual Users (VUs): ${__ENV['DEFAULT_VUS']} \n\n`;
241+
}
233242

234243
markdown += `${header}\n`;
235244
for (const scenario of Object.keys(scenarioMetrics).sort()) {
@@ -248,7 +257,16 @@ function markdownReport(data, isFirstColumnUrl, scenarios) {
248257
const httpMaxDuration = scenarioMetric['http_req_duration'].values['max'].toFixed(2);
249258

250259
const firstColumn = isFirstColumnUrl ? scenarioUrls[scenario] : scenario;
251-
markdown += `| ${firstColumn} | ${__ENV.DEFAULT_VUS} | ${httpReqs} | ${passPercentage} | ${rps} | ${passRps} | ${httpReqDuration} | ${httpMedDuration} | ${httpMinDuration} | ${httpMaxDuration} | ${httpP90Duration} | ${httpP95Duration} | |\n`;
260+
261+
if (testType === 'stress') {
262+
// Show expected RPS for stress test
263+
const expectedRPS = (globalThis.rateAllocation && globalThis.rateAllocation[scenario]) || 'N/A';
264+
markdown += `| ${firstColumn} | ${expectedRPS} | ${httpReqs} | ${passPercentage} | ${rps} | ${passRps} | ${httpReqDuration} | ${httpMedDuration} | ${httpMinDuration} | ${httpMaxDuration} | ${httpP90Duration} | ${httpP95Duration} | |\n`;
265+
} else {
266+
// Show VUs for load/performance test
267+
const actualVUs = (globalThis.vuAllocation && globalThis.vuAllocation[scenario]) || __ENV.DEFAULT_VUS;
268+
markdown += `| ${firstColumn} | ${actualVUs} | ${httpReqs} | ${passPercentage} | ${rps} | ${passRps} | ${httpReqDuration} | ${httpMedDuration} | ${httpMinDuration} | ${httpMaxDuration} | ${httpP90Duration} | ${httpP95Duration} | |\n`;
269+
}
252270
} catch (err) {
253271
console.error(`Unable to render report for scenario ${scenario}`);
254272
}
@@ -265,13 +283,12 @@ function TestScenarioBuilder() {
265283
this._testDuration = undefined;
266284
this._maxDuration = undefined;
267285

268-
this.build = function () {
269-
const that = this;
286+
this.build = () => {
270287
return {
271-
options: getOptionsWithScenario(that._name, that._tags, that._maxDuration, that._testDuration),
272-
run: function (testParameters, iteration = 0, vuIndex = 0, iterationByVu = 0) {
273-
const response = that._request(testParameters, iteration, vuIndex, iterationByVu);
274-
check(response, that._checks);
288+
options: getOptionsWithScenario(this._name, this._tags, this._maxDuration, this._testDuration),
289+
run: (testParameters, iteration = 0, vuIndex = 0, iterationByVu = 0) => {
290+
const response = this._request(testParameters, iteration, vuIndex, iterationByVu);
291+
check(response, this._checks);
275292
// if Load test, then we need to sleep for random time between 1 and 5 seconds
276293
if (getTestType() === 'load') {
277294
sleep(randomIntBetween(1, 5));
@@ -313,4 +330,94 @@ function TestScenarioBuilder() {
313330
return this;
314331
}
315332

316-
export { getSequentialTestScenarios, markdownReport, TestScenarioBuilder };
333+
/**
334+
* Get stress test scenario options for a specific endpoint
335+
*
336+
* Uses the "constant-arrival-rate" executor, which generates a fixed number of iterations (requests) per time unit.
337+
* The executor will dynamically ramp up or lower the number of virtual users as needed to maintain the target request rate.
338+
* This makes it ideal for stress and throughput testing where a steady RPS is required.
339+
*
340+
* See: https://grafana.com/docs/k6/latest/using-k6/scenarios/executors/constant-arrival-rate
341+
*
342+
* @param {string} endpoint - The endpoint name
343+
* @param {number} targetTotalRPS - Target total RPS to distribute
344+
* @param {string} duration - Test duration
345+
* @returns {Object} K6 scenario configuration
346+
*/
347+
export function getStressScenarioOptions(endpoint, targetTotalRPS = 100, duration = '60s') {
348+
const rateAllocation = calculateRateAllocation(targetTotalRPS);
349+
const targetRate = rateAllocation[endpoint];
350+
const VU_BUFFER_MULTIPLIER = parseFloat(__ENV.VU_BUFFER_MULTIPLIER) || 3; // default buffer multiplier
351+
352+
return {
353+
executor: 'constant-arrival-rate',
354+
rate: Math.max(1, Math.round(targetRate)), // requests per second (must be integer, minimum 1)
355+
duration: duration,
356+
preAllocatedVUs: Math.max(1, Math.ceil(targetRate * VU_BUFFER_MULTIPLIER)),
357+
maxVUs: Math.max(1, Math.ceil(3 * targetRate * VU_BUFFER_MULTIPLIER)),
358+
startTime: '0s', // All scenarios start simultaneously for stress testing
359+
gracefulStop: __ENV.DEFAULT_GRACEFUL_STOP || '5s',
360+
exec: 'run',
361+
};
362+
}
363+
364+
/**
365+
* Create concurrent stress test scenarios with realistic traffic distribution
366+
* @param {Object} tests - Test modules object
367+
* @returns {Object} Stress test configuration
368+
*/
369+
function getStressTestScenarios(tests) {
370+
tests = getFilteredTests(tests);
371+
372+
// Use new STRESS_TEST_TARGET_TOTAL_RPS environment variable
373+
const targetTotalRPS = parseInt(__ENV.STRESS_TEST_TARGET_TOTAL_RPS) || 100;
374+
const testDuration = __ENV.DEFAULT_DURATION || '60s';
375+
376+
const funcs = {};
377+
const scenarios = {};
378+
const thresholds = {};
379+
380+
// Create concurrent scenarios for all endpoints
381+
for (const testName of Object.keys(tests).sort()) {
382+
const testModule = tests[testName];
383+
const testScenarios = testModule.options.scenarios;
384+
const testThresholds = testModule.options.thresholds;
385+
386+
for (const [scenarioName] of Object.entries(testScenarios)) {
387+
// Get stress scenario options with realistic RPS allocation
388+
const stressOptions = getStressScenarioOptions(scenarioName, targetTotalRPS, testDuration);
389+
390+
funcs[scenarioName] = testModule[stressOptions.exec];
391+
scenarios[scenarioName] = stressOptions;
392+
393+
// Set up thresholds
394+
const tag = `scenario:${scenarioName}`;
395+
for (const [name, threshold] of Object.entries(testThresholds)) {
396+
if (name === 'http_req_duration') {
397+
thresholds[`${name}{${tag},expected_response:true}`] = threshold;
398+
} else {
399+
thresholds[`${name}{${tag}}`] = threshold;
400+
}
401+
}
402+
thresholds[`http_reqs{${tag}}`] = ['count>0'];
403+
thresholds[`${SCENARIO_DURATION_METRIC_NAME}{${tag}}`] = ['value>0'];
404+
}
405+
}
406+
407+
const testOptions = Object.assign({}, getOptions(), { scenarios, thresholds });
408+
409+
return {
410+
funcs,
411+
options: testOptions,
412+
scenarioDurationGauge: new Gauge(SCENARIO_DURATION_METRIC_NAME),
413+
};
414+
}
415+
416+
export {
417+
getSequentialTestScenarios,
418+
getStressTestScenarios,
419+
markdownReport,
420+
TestScenarioBuilder,
421+
getFilteredTests,
422+
getOptions,
423+
};

k6/src/lib/parameters.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,4 +93,5 @@ export const setDefaultValuesForEnvParameters = () => {
9393
__ENV['DEFAULT_LIMIT'] = __ENV['DEFAULT_LIMIT'] || 100;
9494
__ENV['DEFAULT_PASS_RATE'] = __ENV['DEFAULT_PASS_RATE'] || 0.95;
9595
__ENV['DEFAULT_MAX_DURATION'] = __ENV['DEFAULT_MAX_DURATION'] || 500;
96+
__ENV['STRESS_TEST_TARGET_TOTAL_RPS'] = __ENV['STRESS_TEST_TARGET_TOTAL_RPS'] || 100;
9697
};

k6/src/lib/traffic-weights.js

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
/**
4+
* Traffic weights from real production data (90 days: April 29 - July 29, 2025)
5+
*/
6+
export const trafficWeights = {
7+
eth_getBlockByNumber: 0.687,
8+
eth_getLogs: 0.13,
9+
eth_chainId: 0.0594,
10+
eth_blockNumber: 0.053,
11+
eth_call: 0.0326,
12+
eth_getBlockByHash: 0.0197,
13+
eth_getTransactionReceipt: 0.0082,
14+
eth_getBalance: 0.0064,
15+
debug_traceBlockByNumber: 0.0048,
16+
eth_syncing: 0.0032,
17+
eth_gasPrice: 0.0029,
18+
eth_sendRawTransaction: 0.0028,
19+
eth_getTransactionCount: 0.0023,
20+
net_version: 0.0011,
21+
eth_getTransactionByHash: 0.0008,
22+
eth_estimateGas: 0.0005,
23+
eth_getFilterChanges: 0.0005,
24+
eth_getCode: 0.0003,
25+
debug_traceTransaction: 0.0002,
26+
web3_clientVersion: 0.0002,
27+
eth_getBlockReceipts: 0.0002,
28+
eth_maxPriorityFeePerGas: 0.00012,
29+
eth_getFilterLogs: 0.000063,
30+
net_listening: 0.000058,
31+
eth_feeHistory: 0.000049,
32+
eth_getStorageAt: 0.000045,
33+
eth_newFilter: 0.000021,
34+
eth_uninstallFilter: 0.000008,
35+
eth_submitHashrate: 0.000007,
36+
eth_protocolVersion: 0.000006,
37+
eth_newPendingTransactionFilter: 0.000005,
38+
eth_hashrate: 0.000005,
39+
eth_getTransactionByBlockHashAndIndex: 0.000005,
40+
eth_signTransaction: 0.000004,
41+
eth_getBlockTransactionCountByHash: 0.000004,
42+
eth_coinbase: 0.000004,
43+
eth_sign: 0.000004,
44+
eth_getTransactionByBlockNumberAndIndex: 0.000003,
45+
eth_getUncleByBlockNumberAndIndex: 0.000003,
46+
eth_getUncleByBlockHashAndIndex: 0.000003,
47+
eth_accounts: 0.000003,
48+
eth_getBlockTransactionCountByNumber: 0.000003,
49+
eth_mining: 0.000002,
50+
eth_getUncleCountByBlockNumber: 0.000002,
51+
eth_getUncleCountByBlockHash: 0.000002,
52+
eth_submitWork: 0.000002,
53+
eth_getWork: 0.000002,
54+
eth_sendTransaction: 0.000002,
55+
eth_newBlockFilter: 0.0000003,
56+
eth_blobBaseFee: 0.00000001,
57+
eth_getProof: 0.00000001,
58+
net_peerCount: 0.000000003,
59+
web3_sha3: 0.000000001,
60+
eth_createAccessList: 0.00000001,
61+
};
62+
63+
64+
/**
65+
* Calculate rate allocation based on traffic weights and total RPS
66+
* @param {number} targetTotalRPS - Target total requests per second to distribute
67+
* @returns {Object} RPS allocation per endpoint
68+
*/
69+
export function calculateRateAllocation(targetTotalRPS = 100) {
70+
const allocation = {};
71+
72+
for (const [endpoint, weight] of Object.entries(trafficWeights)) {
73+
// Direct proportional allocation: RPS = targetTotalRPS * percentage
74+
const rps = weight * targetTotalRPS;
75+
// Set minimum rate for very low-traffic endpoints and ensure it's an integer
76+
// k6 requires rate to be an integer, so we round up to ensure at least 1 req/s for active endpoints
77+
allocation[endpoint] = Math.max(1, Math.round(rps));
78+
}
79+
80+
// Store allocation globally for reporting
81+
globalThis.rateAllocation = allocation;
82+
83+
return allocation;
84+
}

k6/src/prepare/prep.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ async function getSignedTxs(wallet, greeterContracts, gasPrice, gasLimit, chainI
9696
console.log('address: ', wallet.address);
9797

9898
// amount to send (HBAR)
99-
let amountInEther = '10';
99+
let amountInEther = process.env.WALLET_BALANCE || '10';
100100
// Create transaction
101101
let tx = {
102102
to: wallet.address,

k6/src/scenarios/stress-test.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
import { textSummary } from 'https://jslib.k6.io/k6-summary/0.0.1/index.js';
4+
import exec from 'k6/execution';
5+
6+
import { setupTestParameters } from '../lib/bootstrapEnvParameters.js';
7+
import { markdownReport } from '../lib/common.js';
8+
import { funcs, options, scenarioDurationGauge } from './stress/index.js';
9+
10+
function handleSummary(data) {
11+
return {
12+
stdout: textSummary(data, { indent: ' ', enableColors: true }),
13+
'stress-test-report.md': markdownReport(data, false, options.scenarios),
14+
};
15+
}
16+
17+
function run(testParameters) {
18+
const scenario = exec.scenario;
19+
funcs[scenario.name](testParameters, scenario.iterationInTest, exec.vu.idInInstance - 1, exec.vu.iterationInScenario);
20+
scenarioDurationGauge.add(Date.now() - scenario.startTime, { scenario: scenario.name });
21+
}
22+
23+
export { handleSummary, options, run };
24+
25+
export const setup = setupTestParameters;

0 commit comments

Comments
 (0)