Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
429 changes: 429 additions & 0 deletions .gitignore

Large diffs are not rendered by default.

35 changes: 35 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"version": "0.2.0",
"configurations": [
{
// Use IntelliSense to find out which attributes exist for C# debugging
// Use hover for the description of the existing attributes
// For further information visit https://github.com/dotnet/vscode-csharp/blob/main/debugger-launchjson.md.
"name": ".NET Core Launch (web)",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
// If you have changed target frameworks, make sure to update the program path.
"program": "${workspaceFolder}/API.Stories/bin/Debug/net9.0/API.Stories.dll",
"args": [],
"cwd": "${workspaceFolder}/API.Stories",
"stopAtEntry": false,
// Enable launching a web browser when ASP.NET Core starts. For more information: https://aka.ms/VSCode-CS-LaunchJson-WebBrowser
"serverReadyAction": {
"action": "openExternally",
"pattern": "\\bNow listening on:\\s+(https?://\\S+)"
},
"env": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"sourceFileMap": {
"/Views": "${workspaceFolder}/Views"
}
},
{
"name": ".NET Core Attach",
"type": "coreclr",
"request": "attach"
}
]
}
41 changes: 41 additions & 0 deletions .vscode/tasks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "build",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/API.Stories/API.Stories.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary;ForceNoAlign"
],
"problemMatcher": "$msCompile"
},
{
"label": "publish",
"command": "dotnet",
"type": "process",
"args": [
"publish",
"${workspaceFolder}/API.Stories/API.Stories.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary;ForceNoAlign"
],
"problemMatcher": "$msCompile"
},
{
"label": "watch",
"command": "dotnet",
"type": "process",
"args": [
"watch",
"run",
"--project",
"${workspaceFolder}/API.Stories/API.Stories.csproj"
],
"problemMatcher": "$msCompile"
}
]
}
3 changes: 3 additions & 0 deletions API.Stories.LoadTests/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules/
reports/*.json
reports/*.html
78 changes: 78 additions & 0 deletions API.Stories.LoadTests/artillery/processor.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
'use strict';

const defaultTopkValues = [1, 5, 10, 20, 30, 50];
const topkValues = parseTopkValues(process.env.TOPK_VALUES);

function parseTopkValues(raw) {
if (!raw || typeof raw !== 'string') {
return defaultTopkValues;
}

const parsed = raw
.split(',')
.map((x) => Number.parseInt(x.trim(), 10))
.filter((x) => Number.isInteger(x) && x > 0);

return parsed.length > 0 ? parsed : defaultTopkValues;
}

function pickTopK(context, events, done) {
const idx = Math.floor(Math.random() * topkValues.length);
context.vars.topk = topkValues[idx];
done();
}

function validateTopKResponse(requestParams, response, context, events, done) {
if (response.statusCode !== 200) {
return done(new Error(`Expected status 200, got ${response.statusCode}`));
}

let body;
try {
body = typeof response.body === 'string' ? JSON.parse(response.body) : response.body;
} catch (err) {
return done(new Error(`Response is not valid JSON: ${err.message}`));
}

if (!Array.isArray(body)) {
return done(new Error('Response body should be a JSON array of stories'));
}

const expectedTopK = Number.parseInt(context.vars.topk || '30', 10);

if (body.length > expectedTopK) {
return done(new Error(`Response returned ${body.length} items, expected at most ${expectedTopK}`));
}

for (let i = 0; i < body.length; i++) {
const item = body[i];

if (typeof item !== 'object' || item === null) {
return done(new Error(`Item at index ${i} is not an object`));
}

if (typeof item.id !== 'number' || typeof item.score !== 'number' || typeof item.title !== 'string') {
return done(new Error(`Item at index ${i} is missing required fields (id, score, title)`));
}

if (i > 0 && body[i - 1].score < item.score) {
return done(new Error('Response is not sorted by score in descending order'));
}
}

context.vars.storyCount = body.length;
done();
}

function recordStoryCount(requestParams, response, context, events, done) {
const count = Number.isInteger(context.vars.storyCount) ? context.vars.storyCount : 0;
events.emit('histogram', 'stories.returned_per_request', count);
events.emit('counter', 'stories.items_returned_total', count);
done();
}

module.exports = {
pickTopK,
validateTopKResponse,
recordStoryCount
};
76 changes: 76 additions & 0 deletions API.Stories.LoadTests/artillery/topk-load-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
config:
target: "http://localhost:5038"
processor: "./processor.cjs"
plugins:
metrics-by-endpoint:
useOnlyRequestNames: true
ensure:
conditions:
- expression: "http.responses > 0"
- expression: "http.codes.200 / http.responses >= 0.95"
- expression: "http.response_time.p95 < 2000"
- expression: "http.response_time.p99 < 4000"
defaults:
headers:
Accept: "application/json"
x-api-version: "1.0"
environments:
smoke:
phases:
- duration: 20
arrivalRate: 1
name: "Smoke ramp"
baseline:
phases:
- duration: 30
arrivalRate: 2
rampTo: 8
name: "Warm-up"
- duration: 90
arrivalRate: 8
rampTo: 20
name: "Steady load"
spike:
phases:
- duration: 30
arrivalRate: 3
rampTo: 10
name: "Ramp-up"
- duration: 30
arrivalRate: 35
rampTo: 80
name: "Spike"
- duration: 30
arrivalRate: 10
name: "Cool-down"
soak:
phases:
- duration: 120
arrivalRate: 8
name: "Soak"

scenarios:
- name: "TopK mixed profile"
weight: 80
flow:
- function: "pickTopK"
- get:
name: "GET /api/v1/topk/:topk"
url: "/api/v1/topk/{{ topk }}"
afterResponse:
- "validateTopKResponse"
- "recordStoryCount"
- think: 1

- name: "Hot path pressure (topk=30)"
weight: 20
flow:
- loop:
- get:
name: "GET /api/v1/topk/30"
url: "/api/v1/topk/30"
afterResponse:
- "validateTopKResponse"
- "recordStoryCount"
- think: 0.5
count: 3
Loading