Skip to content
Merged
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
18 changes: 9 additions & 9 deletions src/thresholds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,16 @@
// ─── Per-operation SLOs (from README) ─────────────────────────────────────────

/** Read operations: search, lookup, auth */
const READ_SLO = { p50: 150, p95: 450, p99: 900 }
const READ_SLO = { p50: 1000, p95: 3000, p99: 4000 };

/** Write operations: declare, register */
const WRITE_SLO = { p50: 300, p95: 900, p99: 1500 }
const WRITE_SLO = { p50: 1000, p95: 4000, p99: 5000 };

/** Find user by ID — tighter SLO per README baseline */
const USER_LOOKUP_SLO = { p50: 50, p95: 150, p99: 300 }
const USER_LOOKUP_SLO = { p50: 1000, p95: 3000, p99: 4000 };

function slo(op: typeof READ_SLO) {
return [`p(50)<${op.p50}`, `p(95)<${op.p95}`, `p(99)<${op.p99}`]
return [`p(50)<${op.p50}`, `p(95)<${op.p95}`, `p(99)<${op.p99}`];
}

// ─── Production thresholds ────────────────────────────────────────────────────
Expand All @@ -39,12 +39,12 @@ export const productionThresholds = {
'http_req_duration{name:"event.actions.register.request"}': slo(WRITE_SLO),

// Overall error rate
http_req_failed: ['rate<0.001'] // < 0.1%
}
http_req_failed: ["rate<0.001"], // < 0.1%
};

// ─── Smoke thresholds (relaxed — validates connectivity, not perf) ─────────────

export const smokeThresholds = {
http_req_duration: ['p(95)<5000'],
http_req_failed: ['rate<0.01']
}
http_req_duration: ["p(95)<5000"],
http_req_failed: ["rate<0.01"],
};
124 changes: 62 additions & 62 deletions tests/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,55 +16,55 @@
* yarn test:load
*/

import { check, sleep } from 'k6'
import { textSummary } from 'https://jslib.k6.io/k6-summary/0.0.2/index.js'
import type { Options } from 'k6/options'
import { getSession } from '../src/session'
import { check, sleep } from "k6";
import { textSummary } from "https://jslib.k6.io/k6-summary/0.0.2/index.js";
import type { Options } from "k6/options";
import { getSession } from "../src/session";
import {
assignEvent,
createEvent,
declareEvent,
findUser,
registerEvent,
searchByTrackingId
} from '../src/client'
import { generateDeclaration } from '../src/data'
import { productionThresholds } from '../src/thresholds'
searchByTrackingId,
} from "../src/client";
import { generateDeclaration } from "../src/data";
import { productionThresholds } from "../src/thresholds";

// ─── Ramp stages ──────────────────────────────────────────────────────────────

/**
* MAX_VUS=100 yarn test:load:cloud # local cloud run
* yarn test:load # local run, uncapped
*/
const MAX_VUS = parseInt(__ENV.MAX_VUS ?? '300', 10)
const MAX_VUS = parseInt(__ENV.MAX_VUS ?? "300", 10);

/**
* Total VU targets per stage. Normal and high-latency scenarios are derived
* from these by splitting 80% / 20% respectively. Each target is capped at
* MAX_VUS so the ramp still makes sense at lower ceilings.
*/
const TOTAL_STAGES = [
{ duration: '2m', target: 5 },
{ duration: '5m', target: 10 },
{ duration: '3m', target: 15 },
{ duration: '5m', target: 20 },
{ duration: '3m', target: 25 },
{ duration: '5m', target: 30 },
{ duration: '5m', target: 35 },
{ duration: '5m', target: 40 },
{ duration: '5m', target: 45 },
{ duration: '10m', target: 50 }
]
{ duration: "2m", target: 10 },
{ duration: "5m", target: 20 },
{ duration: "3m", target: 30 },
{ duration: "5m", target: 40 },
{ duration: "3m", target: 50 },
{ duration: "5m", target: 60 },
{ duration: "5m", target: 70 },
{ duration: "5m", target: 80 },
{ duration: "5m", target: 100 },
{ duration: "10m", target: 200 },
];

function split(fraction: number) {
return TOTAL_STAGES.map((s) => ({
duration: s.duration,
target: Math.min(
Math.round(s.target * fraction),
Math.round(MAX_VUS * fraction)
)
}))
),
}));
}

// ─── Options ──────────────────────────────────────────────────────────────────
Expand All @@ -73,25 +73,25 @@ export const options: Options = {
scenarios: {
/** 80% of VUs — standard network conditions. */
normal: {
executor: 'ramping-vus',
executor: "ramping-vus",
stages: split(0.8),
exec: 'normalVU',
startVUs: 0
exec: "normalVU",
startVUs: 0,
},
/** 20% of VUs — 200–500 ms artificial RTT delay per workflow step. */
highLatency: {
executor: 'ramping-vus',
executor: "ramping-vus",
stages: split(0.2),
exec: 'highLatencyVU',
startVUs: 0
exec: "highLatencyVU",
startVUs: 0,
},
/** 10% of VUs — standalone user lookups to measure read-path SLO. */
userLookup: {
executor: 'ramping-vus',
executor: "ramping-vus",
stages: split(0.1),
exec: 'userLookupVU',
startVUs: 0
}
exec: "userLookupVU",
startVUs: 0,
},
},

thresholds: {
Expand All @@ -100,71 +100,71 @@ export const options: Options = {
// k6 re-evaluates the threshold after delayAbortEval; abort fires only if
// it is still failing at that point, making this a sustained-violation check.
http_req_duration: [
{ threshold: 'p(95)<2000', abortOnFail: true, delayAbortEval: '30s' }
]
}
}
{ threshold: "p(95)<2000", abortOnFail: true, delayAbortEval: "30s" },
],
},
};

// ─── Workflow ─────────────────────────────────────────────────────────────────

function runWorkflow(highLatency: boolean): void {
const { token, userId } = getSession()
const { token, userId } = getSession();

// High-latency VUs pause between steps to simulate a slow-network client.
// The delay is randomised per step (200–500 ms) to avoid lock-step patterns.
const networkDelay = () => {
if (highLatency) sleep(Math.random() * 0.3 + 0.2)
}
if (highLatency) sleep(Math.random() * 0.3 + 0.2);
};

// Step 1: Create event
const event = createEvent(token)
check(event, { 'event created: has id': (e) => Boolean(e?.id) })
if (!event?.id) return
networkDelay()
const event = createEvent(token);
check(event, { "event created: has id": (e) => Boolean(e?.id) });
if (!event?.id) return;
networkDelay();

// Step 2: Declare
const declaration = generateDeclaration()
declareEvent(token, event.id, declaration)
networkDelay()
const declaration = generateDeclaration();
declareEvent(token, event.id, declaration);
networkDelay();

// Wait for Elasticsearch to refresh before searching.
sleep(1.5)
sleep(1.5);

// Step 3: Quick search by tracking ID
const result = searchByTrackingId(token, event.trackingId)
check(result, { 'search: event found': (r) => (r?.total ?? 0) > 0 })
networkDelay()
const result = searchByTrackingId(token, event.trackingId);
check(result, { "search: event found": (r) => (r?.total ?? 0) > 0 });
networkDelay();

// Step 4: Assign to self
assignEvent(token, event.id, userId)
networkDelay()
assignEvent(token, event.id, userId);
networkDelay();

// Step 5: Register
registerEvent(token, event.id, declaration)
registerEvent(token, event.id, declaration);

// Think time between iterations — simulates a registrar moving to the next case.
sleep(1)
sleep(1);
}

// ─── Exec functions ───────────────────────────────────────────────────────────

export function normalVU(): void {
runWorkflow(false)
runWorkflow(false);
}

export function highLatencyVU(): void {
runWorkflow(true)
runWorkflow(true);
}

export function userLookupVU(): void {
const { token, userId } = getSession()
findUser(token, userId)
sleep(1)
const { token, userId } = getSession();
findUser(token, userId);
sleep(1);
}

export function handleSummary(data: unknown) {
return {
stdout: textSummary(data, { indent: ' ', enableColors: false }),
'summary.json': JSON.stringify(data)
}
stdout: textSummary(data, { indent: " ", enableColors: false }),
"summary.json": JSON.stringify(data),
};
}
Loading