1
47import http from "k6/http";
48import encoding from "k6/encoding";
49import crypto from "k6/crypto";
50import { Trend, Rate } from "k6/metrics";
51
52const API_BASE = String(__ENV.K6_API_URL || "http://localhost:4000");
53const SECRET = __ENV.K6_JWT_SECRET || "";
54const SEARCH_URL = String(__ENV.K6_SEARCH_URL || "http://localhost:9200").replace(/\/+$/, "");
55const WORKSPACES = String(__ENV.K6_SEARCH_WORKSPACES || "perf-search-a,perf-search-b")
56 .split(",")
57 .map((s) => s.trim())
58 .filter(Boolean);
59const K6_VERSION = __ENV.K6_VERSION || "unknown";
60const QUERY = String(__ENV.K6_QUERY || "factory search");
61
62
63
64const VUS = parseInt(__ENV.K6_VUS || "100", 10);
65const DURATION_SECONDS = parseInt(__ENV.K6_DURATION_SECONDS || "300", 10);
66const RATE = parseInt(__ENV.K6_RATE || "50", 10);
67
68
69const P95_MS = parseInt(__ENV.K6_P95_MS || "500", 10);
70const P99_MS = parseInt(__ENV.K6_P99_MS || "1000", 10);
71const ERROR_RATE = parseFloat(__ENV.K6_ERROR_RATE || "0.001");
72
73const searchDuration = new Trend("search_duration_ms", true);
74const searchTookMs = new Trend("search_took_ms_server", true);
75const searchErrors = new Rate("search_errors");
76
77
78let SETUP = { osVersion: "unknown", docCount: -1 };
79
80export const options = {
81 scenarios: {
82 search_100k: {
83
84
85 executor: "constant-arrival-rate",
86 rate: RATE,
87 timeUnit: "1s",
88 duration: `${DURATION_SECONDS}s`,
89 preAllocatedVUs: VUS,
90 maxVUs: Math.max(VUS * 2, 20),
91 },
92 },
93 thresholds: {
94 search_duration_ms: [`p(95)<${P95_MS}`, `p(99)<${P99_MS}`],
95 search_errors: [`rate<${ERROR_RATE}`],
96 http_req_failed: [`rate<${ERROR_RATE}`],
97 },
98
99
100
101
102
103 summaryTrendStats: ["avg", "min", "med", "max", "p(90)", "p(95)", "p(99)"],
104};
105
106
107function b64url(input) {
108 return input.replace(/\+/g, "-").replace(/\
109}
110
111
112function vUser(vu) {
113 const i = vu % 200;
114 return { id: `perf-search-u-${String(i).padStart(3, "0")}`, workspace: WORKSPACES[i % WORKSPACES.length] };
115}
116
117
122function mintToken(sub, workspaces) {
123 const nowSec = Math.floor(Date.now() / 1000);
124
125
126
127
128
129
130
131 const header = b64url(encoding.b64encode(JSON.stringify({ alg: "HS256", typ: "JWT" })));
132 const claims = b64url(
133 encoding.b64encode(
134 JSON.stringify({
135 sub: sub,
136 workspaces: workspaces,
137 iat: nowSec,
138 exp: nowSec + 900,
139 jti: `k6-search-${sub}-${nowSec}`,
140 }),
141 ),
142 );
143 const sig = b64url(crypto.hmac("sha256", SECRET, `${header}.${claims}`, "base64"));
144 return `${header}.${claims}.${sig}`;
145}
146
147export function setup() {
148 let osVersion = "unknown";
149 let docCount = -1;
150 try {
151 const v = http.get(SEARCH_URL, { timeout: "10s" });
152 if (v.status === 200) {
153 const j = v.json();
154 osVersion = (j.version && j.version.number) || String(v.status);
155 }
156 } catch (e) {
157
158 }
159 try {
160 const c = http.post(
161 `${SEARCH_URL}/factory-content/_count`,
162 JSON.stringify({ query: { bool: { filter: [{ terms: { workspaceId: WORKSPACES } }] } } }),
163 { headers: { "content-type": "application/json" }, timeout: "10s" },
164 );
165 if (c.status === 200) docCount = c.json().count ?? -1;
166 } catch (e) {
167
168 }
169 SETUP = { osVersion, docCount };
170 if (docCount < 10_000) {
171 console.warn(
172 `[search-100k] WARNING: only ${docCount} fixture docs found (expected ~100k). ` +
173 `Run the seeder first: pnpm --dir tests/performance perf:seed:search`,
174 );
175 }
176 return SETUP;
177}
178
179export default function (data) {
180 SETUP = data;
181 const u = vUser(__VU);
182 const res = http.post(
183 `${API_BASE}/api/v1/search`,
184 JSON.stringify({ q: QUERY, limit: 10 }),
185 {
186 headers: { authorization: `Bearer ${mintToken(u.id, [u.workspace])}`, "content-type": "application/json" },
187 timeout: "10s",
188 },
189 );
190 const ok = res.status === 200;
191 searchDuration.add(res.timings.duration);
192 searchErrors.add(!ok);
193 if (ok) {
194 try {
195 const body = res.json();
196 if (body && typeof body.tookMs === "number") searchTookMs.add(body.tookMs);
197 } catch (e) {
198
199 }
200 }
201}
202
203
208export function handleSummary(data) {
209
210
211
212
213 const v = (data.metrics.search_duration_ms && data.metrics.search_duration_ms.values) || {};
214 const took = (data.metrics.search_took_ms_server && data.metrics.search_took_ms_server.values) || {};
215
216
217
218
219
220
221 let osVersion = "unknown";
222 let docCount = -1;
223 try {
224 const ver = http.get(SEARCH_URL, { timeout: "10s" });
225 if (ver.status === 200) {
226 const j = ver.json();
227 osVersion = (j.version && j.version.number) || String(ver.status);
228 }
229 const c = http.post(
230 `${SEARCH_URL}/factory-content/_count`,
231 JSON.stringify({ query: { bool: { filter: [{ terms: { workspaceId: WORKSPACES } }] } } }),
232 { headers: { "content-type": "application/json" }, timeout: "10s" },
233 );
234 if (c.status === 200) docCount = c.json().count ?? -1;
235 } catch (e) {
236
237 }
238 const report = {
239 task: "TASK-0100 search 100k",
240 generatedAt: new Date().toISOString(),
241 profile: {
242 vus: VUS,
243 durationSeconds: DURATION_SECONDS,
244 ratePerSecond: RATE,
245 query: QUERY,
246 workspaces: WORKSPACES,
247 },
248 latencyMs: {
249 p50: v.med ?? null,
250 p95: v["p(95)"] ?? null,
251 p99: v["p(99)"] ?? null,
252 avg: v.avg ?? null,
253 },
254 serverTookMs: {
255 p50: took.med ?? null,
256 p95: took["p(95)"] ?? null,
257 p99: took["p(99)"] ?? null,
258 },
259 errorRate: (data.metrics.search_errors && data.metrics.search_errors.values && data.metrics.search_errors.values.rate) ?? null,
260 docCount,
261 fingerprint: {
262 k6Version: K6_VERSION,
263 opensearchVersion: osVersion,
264 thresholds: { p95Ms: P95_MS, p99Ms: P99_MS, errorRate: ERROR_RATE },
265 },
266 };
267 const json = JSON.stringify(report, null, 2);
268 return {
269 "reports/search-last.json": json,
270 "search-summary.json": json,
271 };
272}
273