@loadstrike/loadstrike-sdk 0.1.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.
@@ -0,0 +1,403 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { EndpointAdapterFactory } from "./transports.js";
3
+ export class LocalClusterCoordinator {
4
+ constructor(options = {}) {
5
+ this.options = options;
6
+ }
7
+ async dispatch(agents, scenarioNames, assignmentOptions = {}) {
8
+ const timeoutMs = Math.max(this.options.commandTimeoutMs ?? 120000, 1);
9
+ const retryCount = Math.max(this.options.retryCount ?? 0, 0);
10
+ const assignments = planAgentScenarioAssignments(scenarioNames, agents.length, assignmentOptions);
11
+ const tasks = agents.map((agent, index) => {
12
+ const dispatch = { scenarioNames: assignments[index] ?? [] };
13
+ return this.executeWithRetry(agent, dispatch, timeoutMs, retryCount);
14
+ });
15
+ const nodeResults = await Promise.all(tasks);
16
+ return {
17
+ nodeResults,
18
+ allRequestCount: nodeResults.reduce((sum, x) => sum + x.allRequestCount, 0),
19
+ allOkCount: nodeResults.reduce((sum, x) => sum + x.allOkCount, 0),
20
+ allFailCount: nodeResults.reduce((sum, x) => sum + x.allFailCount, 0),
21
+ failedNodes: nodeResults.filter((x) => !x.success).length
22
+ };
23
+ }
24
+ async executeWithRetry(agent, dispatch, timeoutMs, retryCount) {
25
+ let lastResult = {
26
+ nodeId: agent.nodeId,
27
+ success: false,
28
+ allRequestCount: 0,
29
+ allOkCount: 0,
30
+ allFailCount: 0,
31
+ error: "agent did not execute"
32
+ };
33
+ for (let attempt = 0; attempt <= retryCount; attempt += 1) {
34
+ lastResult = await this.withTimeout(agent.execute(dispatch), timeoutMs, agent.nodeId);
35
+ if (lastResult.success) {
36
+ return lastResult;
37
+ }
38
+ }
39
+ return lastResult;
40
+ }
41
+ async withTimeout(run, timeoutMs, nodeId) {
42
+ let timeoutHandle;
43
+ const timeout = new Promise((resolve) => {
44
+ timeoutHandle = setTimeout(() => {
45
+ resolve({
46
+ nodeId,
47
+ success: false,
48
+ allRequestCount: 0,
49
+ allOkCount: 0,
50
+ allFailCount: 0,
51
+ error: `node timed out after ${timeoutMs}ms`
52
+ });
53
+ }, timeoutMs);
54
+ });
55
+ const result = await Promise.race([run, timeout]);
56
+ if (timeoutHandle) {
57
+ clearTimeout(timeoutHandle);
58
+ }
59
+ return result;
60
+ }
61
+ }
62
+ export function planAgentScenarioAssignments(scenarioNames, agentCount, options = {}) {
63
+ const totalAgents = Math.max(agentCount, 0);
64
+ const assignments = Array.from({ length: totalAgents }, () => []);
65
+ if (totalAgents === 0) {
66
+ return assignments;
67
+ }
68
+ if (options.explicitAssignments?.length) {
69
+ for (let i = 0; i < totalAgents; i += 1) {
70
+ assignments[i] = [...(options.explicitAssignments[i] ?? [])];
71
+ }
72
+ return assignments;
73
+ }
74
+ const selected = new Set((options.selectedScenarioNames ?? scenarioNames).map((x) => String(x)));
75
+ const ordered = scenarioNames.filter((name) => selected.has(name));
76
+ if (!ordered.length) {
77
+ return assignments;
78
+ }
79
+ const weights = Array.from({ length: totalAgents }, (_, i) => {
80
+ const raw = options.agentWeights?.[i];
81
+ if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) {
82
+ return Math.max(Math.trunc(raw), 1);
83
+ }
84
+ return 1;
85
+ });
86
+ const weightedCycle = buildWeightedCycle(weights);
87
+ let cycleIndex = 0;
88
+ const targets = Array.from({ length: totalAgents }, (_, i) => {
89
+ const values = options.agentTargets?.[i];
90
+ if (!values?.length) {
91
+ return null;
92
+ }
93
+ return new Set(values.map((x) => String(x)));
94
+ });
95
+ for (const scenarioName of ordered) {
96
+ const eligible = targets
97
+ .map((target, index) => ({ index, target }))
98
+ .filter((row) => row.target == null || row.target.has(scenarioName))
99
+ .map((row) => row.index);
100
+ if (!eligible.length) {
101
+ continue;
102
+ }
103
+ let selectedAgent = eligible[0];
104
+ for (let attempt = 0; attempt < weightedCycle.length; attempt += 1) {
105
+ const candidate = weightedCycle[(cycleIndex + attempt) % weightedCycle.length];
106
+ if (eligible.includes(candidate)) {
107
+ selectedAgent = candidate;
108
+ cycleIndex = (cycleIndex + attempt + 1) % weightedCycle.length;
109
+ break;
110
+ }
111
+ }
112
+ assignments[selectedAgent].push(scenarioName);
113
+ }
114
+ return assignments;
115
+ }
116
+ export class DistributedClusterCoordinator {
117
+ constructor(options) {
118
+ this.options = options;
119
+ }
120
+ async dispatch(assignments) {
121
+ const expected = Math.max(this.options.expectedAgentResults, 0);
122
+ const timeoutMs = Math.max(this.options.commandTimeoutMs ?? 120000, 1);
123
+ const runSubject = this.buildRunSubject();
124
+ const replySubject = this.buildReplySubject();
125
+ const commandProducer = EndpointAdapterFactory.create(this.natsEndpoint("Produce", "coordinator-command", runSubject));
126
+ const resultConsumer = EndpointAdapterFactory.create(this.natsEndpoint("Consume", "coordinator-result", replySubject));
127
+ try {
128
+ // Prime the reply subscription before commands are published because core NATS subjects do not retain messages.
129
+ await resultConsumer.consume();
130
+ for (let i = 0; i < assignments.length; i += 1) {
131
+ const commandId = randomUUID().replace(/-/g, "");
132
+ const command = {
133
+ commandId,
134
+ sessionId: this.options.sessionId,
135
+ testSuite: this.options.testSuite,
136
+ testName: this.options.testName,
137
+ agentIndex: i,
138
+ agentCount: expected,
139
+ targetScenarios: assignments[i] ?? [],
140
+ replySubject
141
+ };
142
+ await commandProducer.produce({
143
+ headers: {
144
+ "x-cluster-command-id": commandId,
145
+ "x-cluster-reply-subject": replySubject
146
+ },
147
+ body: command
148
+ });
149
+ }
150
+ const byCommand = new Map();
151
+ const deadline = Date.now() + timeoutMs;
152
+ while (byCommand.size < expected && Date.now() < deadline) {
153
+ const payload = await resultConsumer.consume();
154
+ if (!payload?.body || typeof payload.body !== "object" || Array.isArray(payload.body)) {
155
+ await sleep(5);
156
+ continue;
157
+ }
158
+ const result = parseRunResult(payload.body);
159
+ if (!result.commandId) {
160
+ continue;
161
+ }
162
+ byCommand.set(result.commandId, result);
163
+ }
164
+ const nodeResults = Array.from(byCommand.values()).map((result) => convertRunResult(result));
165
+ return {
166
+ nodeResults,
167
+ allRequestCount: nodeResults.reduce((sum, x) => sum + x.allRequestCount, 0),
168
+ allOkCount: nodeResults.reduce((sum, x) => sum + x.allOkCount, 0),
169
+ allFailCount: nodeResults.reduce((sum, x) => sum + x.allFailCount, 0),
170
+ failedNodes: nodeResults.filter((x) => !x.success).length + Math.max(expected - nodeResults.length, 0),
171
+ missingNodes: Math.max(expected - nodeResults.length, 0),
172
+ runSubject,
173
+ replySubject
174
+ };
175
+ }
176
+ finally {
177
+ await resultConsumer.dispose?.().catch(() => { });
178
+ await commandProducer.dispose?.().catch(() => { });
179
+ }
180
+ }
181
+ buildRunSubject() {
182
+ return `loadstrike.${sanitizeToken(this.options.clusterId)}.${sanitizeToken(this.options.agentGroup ?? "default")}.run`;
183
+ }
184
+ buildReplySubject() {
185
+ return `loadstrike.${sanitizeToken(this.options.clusterId)}.${sanitizeToken(this.options.sessionId)}.reply.${randomUUID().replace(/-/g, "")}`;
186
+ }
187
+ natsEndpoint(mode, name, subject) {
188
+ return {
189
+ kind: "Nats",
190
+ mode,
191
+ name,
192
+ trackingField: "header:x-cluster-command-id",
193
+ nats: {
194
+ ...(this.options.nats ?? {}),
195
+ Subject: subject,
196
+ StartFromEarliest: true
197
+ }
198
+ };
199
+ }
200
+ }
201
+ export class DistributedClusterAgent {
202
+ constructor(options) {
203
+ this.commandConsumer = null;
204
+ this.options = options;
205
+ }
206
+ async dispose() {
207
+ await this.commandConsumer?.dispose?.().catch(() => { });
208
+ this.commandConsumer = null;
209
+ }
210
+ async pollAndExecuteOnce(execute) {
211
+ const runSubject = `loadstrike.${sanitizeToken(this.options.clusterId)}.${sanitizeToken(this.options.agentGroup ?? "default")}.run`;
212
+ const queueGroup = `loadstrike.${sanitizeToken(this.options.clusterId)}.${sanitizeToken(this.options.agentGroup ?? "default")}.agents`;
213
+ if (!this.commandConsumer) {
214
+ this.commandConsumer = EndpointAdapterFactory.create({
215
+ kind: "Nats",
216
+ mode: "Consume",
217
+ name: `agent-${this.options.agentId}`,
218
+ trackingField: "header:x-cluster-command-id",
219
+ nats: {
220
+ ...(this.options.nats ?? {}),
221
+ Subject: runSubject,
222
+ QueueGroup: queueGroup,
223
+ StartFromEarliest: true
224
+ }
225
+ });
226
+ }
227
+ const payload = await this.commandConsumer.consume();
228
+ if (!payload?.body || typeof payload.body !== "object" || Array.isArray(payload.body)) {
229
+ return false;
230
+ }
231
+ const command = parseRunCommand(payload.body);
232
+ if (!command.commandId) {
233
+ return false;
234
+ }
235
+ const replySubject = stringOrDefault(payload.headers?.["x-cluster-reply-subject"], command.replySubject ?? "");
236
+ const resultProducer = replySubject
237
+ ? EndpointAdapterFactory.create({
238
+ kind: "Nats",
239
+ mode: "Produce",
240
+ name: `agent-${this.options.agentId}-result`,
241
+ trackingField: "header:x-cluster-command-id",
242
+ nats: {
243
+ ...(this.options.nats ?? {}),
244
+ Subject: replySubject
245
+ }
246
+ })
247
+ : null;
248
+ try {
249
+ let result;
250
+ try {
251
+ const nodeResult = await execute({ scenarioNames: command.targetScenarios });
252
+ result = {
253
+ commandId: command.commandId,
254
+ agentId: this.options.agentId,
255
+ isSuccess: nodeResult.success,
256
+ errorMessage: nodeResult.error,
257
+ stats: nodeResult.stats ?? {
258
+ allRequestCount: nodeResult.allRequestCount,
259
+ allOkCount: nodeResult.allOkCount,
260
+ allFailCount: nodeResult.allFailCount
261
+ }
262
+ };
263
+ }
264
+ catch (error) {
265
+ result = {
266
+ commandId: command.commandId,
267
+ agentId: this.options.agentId,
268
+ isSuccess: false,
269
+ errorMessage: String(error ?? "agent execution failed")
270
+ };
271
+ }
272
+ if (resultProducer) {
273
+ await resultProducer.produce({
274
+ headers: {
275
+ "x-cluster-command-id": result.commandId
276
+ },
277
+ body: result
278
+ });
279
+ }
280
+ return true;
281
+ }
282
+ finally {
283
+ await resultProducer?.dispose?.().catch(() => { });
284
+ }
285
+ }
286
+ }
287
+ function parseRunCommand(value) {
288
+ const targetScenarios = Array.isArray(value.targetScenarios)
289
+ ? value.targetScenarios.map((x) => String(x))
290
+ : Array.isArray(value.TargetScenarios)
291
+ ? value.TargetScenarios.map((x) => String(x))
292
+ : [];
293
+ return {
294
+ commandId: stringOrDefault(value.commandId, stringOrDefault(value.CommandId, "")),
295
+ sessionId: stringOrDefault(value.sessionId, stringOrDefault(value.SessionId, "")),
296
+ testSuite: stringOrDefault(value.testSuite, stringOrDefault(value.TestSuite, "")),
297
+ testName: stringOrDefault(value.testName, stringOrDefault(value.TestName, "")),
298
+ agentIndex: numberOrDefault(value.agentIndex, numberOrDefault(value.AgentIndex, 0)),
299
+ agentCount: numberOrDefault(value.agentCount, numberOrDefault(value.AgentCount, 0)),
300
+ targetScenarios,
301
+ replySubject: stringOrDefault(value.replySubject, stringOrDefault(value.ReplySubject, ""))
302
+ };
303
+ }
304
+ function parseRunResult(value) {
305
+ const statsValue = value.stats ?? value.Stats;
306
+ const statsRecord = statsValue && typeof statsValue === "object" && !Array.isArray(statsValue)
307
+ ? statsValue
308
+ : null;
309
+ return {
310
+ commandId: stringOrDefault(value.commandId, stringOrDefault(value.CommandId, "")),
311
+ agentId: stringOrDefault(value.agentId, stringOrDefault(value.AgentId, "")),
312
+ isSuccess: booleanOrDefault(value.isSuccess, booleanOrDefault(value.IsSuccess, false)),
313
+ errorMessage: stringOrDefault(value.errorMessage, stringOrDefault(value.ErrorMessage, "")),
314
+ stats: statsRecord
315
+ ? {
316
+ allBytes: numberOrDefault(statsRecord.allBytes, numberOrDefault(statsRecord.AllBytes, 0)),
317
+ allRequestCount: numberOrDefault(statsRecord.allRequestCount, numberOrDefault(statsRecord.AllRequestCount, 0)),
318
+ allOkCount: numberOrDefault(statsRecord.allOkCount, numberOrDefault(statsRecord.AllOkCount, 0)),
319
+ allFailCount: numberOrDefault(statsRecord.allFailCount, numberOrDefault(statsRecord.AllFailCount, 0)),
320
+ durationMs: numberOrDefault(statsRecord.durationMs, numberOrDefault(statsRecord.DurationMs, 0)),
321
+ metrics: recordOrUndefined(statsRecord.metrics ?? statsRecord.Metrics),
322
+ scenarioStats: arrayOrUndefined(statsRecord.scenarioStats ?? statsRecord.ScenarioStats),
323
+ thresholds: arrayOrUndefined(statsRecord.thresholds ?? statsRecord.Thresholds),
324
+ pluginsData: arrayOrUndefined(statsRecord.pluginsData ?? statsRecord.PluginsData),
325
+ nodeInfo: recordOrUndefined(statsRecord.nodeInfo ?? statsRecord.NodeInfo),
326
+ testInfo: recordOrUndefined(statsRecord.testInfo ?? statsRecord.TestInfo)
327
+ }
328
+ : undefined
329
+ };
330
+ }
331
+ function convertRunResult(result) {
332
+ return {
333
+ nodeId: result.agentId || "unknown-agent",
334
+ success: result.isSuccess,
335
+ allRequestCount: result.stats?.allRequestCount ?? 0,
336
+ allOkCount: result.stats?.allOkCount ?? 0,
337
+ allFailCount: result.stats?.allFailCount ?? 0,
338
+ stats: result.stats,
339
+ error: result.errorMessage ?? ""
340
+ };
341
+ }
342
+ function sanitizeToken(value) {
343
+ if (!value.trim()) {
344
+ return "default";
345
+ }
346
+ return value.replace(/[^a-zA-Z0-9\-_]/g, "_");
347
+ }
348
+ function buildWeightedCycle(weights) {
349
+ const cycle = [];
350
+ for (let i = 0; i < weights.length; i += 1) {
351
+ for (let repeat = 0; repeat < Math.max(weights[i], 1); repeat += 1) {
352
+ cycle.push(i);
353
+ }
354
+ }
355
+ return cycle.length ? cycle : [0];
356
+ }
357
+ function stringOrDefault(value, fallback) {
358
+ if (typeof value === "string" && value.trim()) {
359
+ return value;
360
+ }
361
+ return fallback;
362
+ }
363
+ function numberOrDefault(value, fallback) {
364
+ if (typeof value === "number" && Number.isFinite(value)) {
365
+ return value;
366
+ }
367
+ if (typeof value === "string" && value.trim()) {
368
+ const parsed = Number(value);
369
+ if (Number.isFinite(parsed)) {
370
+ return parsed;
371
+ }
372
+ }
373
+ return fallback;
374
+ }
375
+ function recordOrUndefined(value) {
376
+ return value && typeof value === "object" && !Array.isArray(value)
377
+ ? value
378
+ : undefined;
379
+ }
380
+ function arrayOrUndefined(value) {
381
+ return Array.isArray(value) ? value : undefined;
382
+ }
383
+ function booleanOrDefault(value, fallback) {
384
+ if (typeof value === "boolean") {
385
+ return value;
386
+ }
387
+ if (typeof value === "number") {
388
+ return value !== 0;
389
+ }
390
+ if (typeof value === "string" && value.trim()) {
391
+ const normalized = value.trim().toLowerCase();
392
+ if (normalized === "true" || normalized === "1" || normalized === "yes") {
393
+ return true;
394
+ }
395
+ if (normalized === "false" || normalized === "0" || normalized === "no") {
396
+ return false;
397
+ }
398
+ }
399
+ return fallback;
400
+ }
401
+ async function sleep(ms) {
402
+ await new Promise((resolve) => setTimeout(resolve, ms));
403
+ }
@@ -0,0 +1 @@
1
+ export {};