@glubean/runner 0.7.0 → 0.8.1

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.
Files changed (71) hide show
  1. package/dist/engine-bridge.d.ts +4 -0
  2. package/dist/engine-bridge.d.ts.map +1 -1
  3. package/dist/engine-bridge.js +10 -1
  4. package/dist/engine-bridge.js.map +1 -1
  5. package/dist/executor.d.ts +2 -2
  6. package/dist/executor.d.ts.map +1 -1
  7. package/dist/executor.js +9 -226
  8. package/dist/executor.js.map +1 -1
  9. package/dist/harness.js +3 -79
  10. package/dist/harness.js.map +1 -1
  11. package/dist/index.d.ts +16 -0
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +17 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/load/continuation-pool.d.ts +82 -0
  16. package/dist/load/continuation-pool.d.ts.map +1 -0
  17. package/dist/load/continuation-pool.js +154 -0
  18. package/dist/load/continuation-pool.js.map +1 -0
  19. package/dist/load/execute-iteration.d.ts +126 -0
  20. package/dist/load/execute-iteration.d.ts.map +1 -0
  21. package/dist/load/execute-iteration.js +367 -0
  22. package/dist/load/execute-iteration.js.map +1 -0
  23. package/dist/load/histogram.d.ts +63 -0
  24. package/dist/load/histogram.d.ts.map +1 -0
  25. package/dist/load/histogram.js +149 -0
  26. package/dist/load/histogram.js.map +1 -0
  27. package/dist/load/orchestrator.d.ts +55 -0
  28. package/dist/load/orchestrator.d.ts.map +1 -0
  29. package/dist/load/orchestrator.js +571 -0
  30. package/dist/load/orchestrator.js.map +1 -0
  31. package/dist/load/reducer.d.ts +109 -0
  32. package/dist/load/reducer.d.ts.map +1 -0
  33. package/dist/load/reducer.js +718 -0
  34. package/dist/load/reducer.js.map +1 -0
  35. package/dist/load/route-key.d.ts +38 -0
  36. package/dist/load/route-key.d.ts.map +1 -0
  37. package/dist/load/route-key.js +107 -0
  38. package/dist/load/route-key.js.map +1 -0
  39. package/dist/load/samples.d.ts +83 -0
  40. package/dist/load/samples.d.ts.map +1 -0
  41. package/dist/load/samples.js +269 -0
  42. package/dist/load/samples.js.map +1 -0
  43. package/dist/load/sink.d.ts +127 -0
  44. package/dist/load/sink.d.ts.map +1 -0
  45. package/dist/load/sink.js +351 -0
  46. package/dist/load/sink.js.map +1 -0
  47. package/dist/load/subprocess.d.ts +83 -0
  48. package/dist/load/subprocess.d.ts.map +1 -0
  49. package/dist/load/subprocess.js +229 -0
  50. package/dist/load/subprocess.js.map +1 -0
  51. package/dist/load/threshold.d.ts +44 -0
  52. package/dist/load/threshold.d.ts.map +1 -0
  53. package/dist/load/threshold.js +197 -0
  54. package/dist/load/threshold.js.map +1 -0
  55. package/dist/load/timeline.d.ts +36 -0
  56. package/dist/load/timeline.d.ts.map +1 -0
  57. package/dist/load/timeline.js +158 -0
  58. package/dist/load/timeline.js.map +1 -0
  59. package/dist/load-harness.d.ts +2 -0
  60. package/dist/load-harness.d.ts.map +1 -0
  61. package/dist/load-harness.js +105 -0
  62. package/dist/load-harness.js.map +1 -0
  63. package/dist/runner-resolve.d.ts +53 -0
  64. package/dist/runner-resolve.d.ts.map +1 -0
  65. package/dist/runner-resolve.js +264 -0
  66. package/dist/runner-resolve.js.map +1 -0
  67. package/dist/workflow/event-timeline.d.ts +3 -0
  68. package/dist/workflow/event-timeline.d.ts.map +1 -0
  69. package/dist/workflow/event-timeline.js +72 -0
  70. package/dist/workflow/event-timeline.js.map +1 -0
  71. package/package.json +4 -4
@@ -0,0 +1,718 @@
1
+ import { LoadHistogram } from "./histogram.js";
2
+ import { LoadTimeline } from "./timeline.js";
3
+ import { LoadSampleCollector } from "./samples.js";
4
+ const SEP = "\u0000";
5
+ const ZERO_PCT = { p50: 0, p90: 0, p95: 0, p99: 0, max: 0 };
6
+ // Fixed latency ladder (ms) for the distribution histograms. Fixed (not data-derived) so two
7
+ // runs' distributions line up bucket-for-bucket; a final overflow bucket carries the tail.
8
+ const LATENCY_LADDER = [10, 25, 50, 75, 100, 150, 200, 300, 500, 750, 1000, 2000, 5000];
9
+ function rate(failed, total) {
10
+ return total > 0 ? failed / total : 0;
11
+ }
12
+ export class LoadReducerImpl {
13
+ runnerId = "";
14
+ config;
15
+ requestedConcurrency = 0;
16
+ endReason;
17
+ crash;
18
+ firstTs;
19
+ lastTs = 0;
20
+ // transactions (iterations)
21
+ iterStarted = 0;
22
+ iterCompleted = 0;
23
+ iterSucceeded = 0;
24
+ iterFailed = 0;
25
+ iterLatency = new LoadHistogram();
26
+ // primary-phase boundary (M5): present only when a scenario calls
27
+ // `ctx.report.primaryComplete()`. `primaryLatency` aggregates `primaryDurationMs`
28
+ // (iteration start → boundary); `iterHadPrimary` tracks in-flight iterations that
29
+ // reached the boundary so a later failure is classified before/after it.
30
+ primaryBoundaries = 0;
31
+ failedBeforePrimary = 0;
32
+ endedWithoutBoundary = 0;
33
+ primaryLatency = new LoadHistogram();
34
+ iterHadPrimary = new Set();
35
+ // continuation / producer-release (M6): present once any release is attempted.
36
+ releasedProducerSlots = 0;
37
+ rejectedReleaseSignals = 0;
38
+ duplicateReleaseSignals = 0;
39
+ maxContinuationBacklog = 0;
40
+ continuationBackpressure = new LoadHistogram();
41
+ // Released iterations still in their continuation phase (released, not yet ended):
42
+ // the LIVE in-flight count for snapshot() and an interrupted finalize().
43
+ liveContinuations = new Set();
44
+ // Iterations that reached their boundary requesting release but aren't yet granted
45
+ // / rejected — i.e. parked on a full backlog. They've left the primary phase but
46
+ // aren't continuations yet, so they're the LIVE `blockedOnBacklog` count.
47
+ pendingRelease = new Set();
48
+ // Per in-flight iteration: each step's START phase (recorded at step:start), so a
49
+ // step's step:end + requests aggregate under the phase it started in even after a
50
+ // mid-step boundary flips the live phase. Cleared at iteration:end (bounded).
51
+ iterStepPhase = new Map();
52
+ scenarios = new Map();
53
+ steps = new Map();
54
+ endpoints = new Map();
55
+ matrix = new Map();
56
+ recentFailures = [];
57
+ // Over-time series (RPS / error-rate / latency / concurrency vs time), bucketed by the
58
+ // event ts offset from the run start.
59
+ timeline = new LoadTimeline();
60
+ // Bounded failure-trace / slow-transaction samples (the "show me one" view).
61
+ samples;
62
+ /** @param reportCaps `report` sample caps (0 disables a sample type entirely). */
63
+ constructor(reportCaps = {}) {
64
+ this.samples = new LoadSampleCollector(reportCaps);
65
+ }
66
+ apply(event) {
67
+ if (this.firstTs === undefined)
68
+ this.firstTs = event.ts;
69
+ if (event.ts > this.lastTs)
70
+ this.lastTs = event.ts;
71
+ if (event.runnerId)
72
+ this.runnerId = event.runnerId;
73
+ switch (event.type) {
74
+ case "load:start":
75
+ this.config = event.config;
76
+ this.requestedConcurrency = event.config.concurrency;
77
+ // Seed an aggregate for every configured traffic-mix entry, so a low-weight entry
78
+ // that never gets selected still appears in the artifact (configured, 0 iterations)
79
+ // rather than absent. A single-scenario run has no `scenarios` and seeds on its
80
+ // first iteration:start, as before.
81
+ for (const s of event.config.scenarios ?? []) {
82
+ this.scenarioAgg(s.scenarioId, s.scenarioRefId);
83
+ }
84
+ break;
85
+ case "load:end":
86
+ this.endReason = event.reason;
87
+ if (event.crash)
88
+ this.crash = event.crash;
89
+ break;
90
+ case "iteration:start":
91
+ this.iterStarted += 1;
92
+ // Concurrency-over-time: sample the live in-flight (post-increment local peak); the
93
+ // window also carries the count forward at finalize.
94
+ this.timeline.recordIterationStart(this.offsetOf(event.ts), this.iterStarted - this.iterCompleted);
95
+ if (event.iterationId) {
96
+ this.samples.beginIteration(event.iterationId, {
97
+ scenarioId: event.scenarioId ?? "",
98
+ ...(event.scenarioRefId !== undefined ? { scenarioRefId: event.scenarioRefId } : {}),
99
+ producerSlotId: event.producerSlotId ?? "",
100
+ ...(event.feederKeys !== undefined ? { feederKeys: event.feederKeys } : {}),
101
+ });
102
+ }
103
+ // Seed the scenario aggregate (no completed-count change) so a run that
104
+ // aborts/crashes before this scenario's first iteration:end still keeps
105
+ // scenario-level attribution in the finalized artifact.
106
+ this.scenarioAgg(event.scenarioId, event.scenarioRefId);
107
+ break;
108
+ case "iteration:end": {
109
+ this.iterCompleted += 1;
110
+ if (event.ok)
111
+ this.iterSucceeded += 1;
112
+ else
113
+ this.iterFailed += 1;
114
+ this.iterLatency.record(event.durationMs);
115
+ this.timeline.recordIterationEnd(this.offsetOf(event.ts)); // iteration throughput over time
116
+ // Boundary accounting (M5): did this iteration reach its primary boundary?
117
+ // A failure WITHOUT a boundary failed before primary completion.
118
+ const hadBoundary = event.iterationId ? this.iterHadPrimary.delete(event.iterationId) : false;
119
+ if (!hadBoundary) {
120
+ this.endedWithoutBoundary += 1;
121
+ // A no-boundary iteration has no continuation phase — its whole run IS its
122
+ // primary phase. On success it COMPLETED primary (record its full duration
123
+ // as primary latency); on failure it failed before any boundary.
124
+ if (!event.ok)
125
+ this.failedBeforePrimary += 1;
126
+ else
127
+ this.primaryLatency.record(event.durationMs);
128
+ }
129
+ if (event.iterationId) {
130
+ this.samples.endIteration(event.iterationId, {
131
+ ok: event.ok,
132
+ durationMs: event.durationMs,
133
+ ...(event.errorKind !== undefined ? { errorKind: event.errorKind } : {}),
134
+ });
135
+ this.iterStepPhase.delete(event.iterationId); // bounded cleanup
136
+ this.liveContinuations.delete(event.iterationId); // continuation finished
137
+ this.pendingRelease.delete(event.iterationId); // a fail-iteration tail ended
138
+ }
139
+ const sc = this.scenarioAgg(event.scenarioId, event.scenarioRefId);
140
+ if (sc) {
141
+ sc.iterations += 1;
142
+ if (event.ok)
143
+ sc.successful += 1;
144
+ else
145
+ sc.failed += 1;
146
+ sc.latency.record(event.durationMs);
147
+ }
148
+ if (!event.ok) {
149
+ this.pushFailure({
150
+ scenarioId: event.scenarioId,
151
+ scenarioRefId: event.scenarioRefId,
152
+ iterationId: event.iterationId ?? "",
153
+ errorKind: event.errorKind,
154
+ atMs: this.firstTs !== undefined ? event.ts - this.firstTs : 0,
155
+ });
156
+ }
157
+ break;
158
+ }
159
+ case "step:start": {
160
+ const stepId = event.stepId ?? event.stepName;
161
+ // step:start carries the step's START phase — record it so this step's later
162
+ // events (step:end, requests) all resolve to it, even after a mid-step flip.
163
+ const phase = this.phaseOf(event);
164
+ this.rememberStepPhase(event.iterationId, stepId, phase);
165
+ const step = this.getStep(event.scenarioId, event.scenarioRefId, stepId, phase, event.stepName);
166
+ // groupId is only on step:start — capture it now (step:end omits it).
167
+ if (event.groupId !== undefined)
168
+ step.groupId = event.groupId;
169
+ break;
170
+ }
171
+ case "step:end": {
172
+ const stepId = event.stepId ?? event.stepName;
173
+ const step = this.getStep(event.scenarioId, event.scenarioRefId, stepId, this.stepStartPhase(event.iterationId, stepId, this.phaseOf(event)), event.stepName);
174
+ step.stepName = event.stepName; // correct any placeholder set by an earlier request:observed
175
+ // A skipped leaf emits no step:start, so capture its group here too.
176
+ if (event.groupId !== undefined && step.groupId === undefined)
177
+ step.groupId = event.groupId;
178
+ step.invocationCount += 1;
179
+ if (event.skipped) {
180
+ // A skipped step (fail-fast / skipRemainingSteps) is neither a latency
181
+ // sample nor a failure of its own — only an explicit skip.
182
+ step.skippedCount += 1;
183
+ }
184
+ else {
185
+ step.latency.record(event.durationMs);
186
+ if (!event.ok)
187
+ step.errorCount += 1;
188
+ }
189
+ step.assertionFailureCount += event.assertionFailures ?? 0;
190
+ if (event.iterationId) {
191
+ this.samples.recordStep(event.iterationId, {
192
+ stepId,
193
+ stepName: event.stepName,
194
+ durationMs: event.durationMs,
195
+ ok: event.ok,
196
+ skipped: event.skipped ?? false,
197
+ });
198
+ }
199
+ break;
200
+ }
201
+ case "request:observed": {
202
+ // RPS / error-rate / latency over time, with a live-in-flight sample (a request-busy
203
+ // window — e.g. a poll — still shows concurrency).
204
+ this.timeline.recordRequest(this.offsetOf(event.ts), event.durationMs, event.ok, this.iterStarted - this.iterCompleted);
205
+ if (event.iterationId) {
206
+ this.samples.recordRequest(event.iterationId, {
207
+ tsOffsetMs: this.offsetOf(event.ts),
208
+ ...(event.stepId !== undefined ? { stepId: event.stepId } : {}),
209
+ phase: this.phaseOf(event),
210
+ method: event.method,
211
+ routeKey: event.routeKey,
212
+ ...(event.status !== undefined ? { status: event.status } : {}),
213
+ ok: event.ok,
214
+ durationMs: event.durationMs,
215
+ ...(event.errorKind !== undefined ? { errorKind: event.errorKind } : {}),
216
+ });
217
+ }
218
+ const ep = this.endpointAgg(event);
219
+ ep.requestCount += 1;
220
+ if (!event.ok)
221
+ ep.errorCount += 1;
222
+ ep.latency.record(event.durationMs);
223
+ const statusKey = event.status === undefined ? "error" : String(event.status);
224
+ ep.statusCounts[statusKey] = (ep.statusCounts[statusKey] ?? 0) + 1;
225
+ const cell = this.matrixAgg(event);
226
+ cell.requestCount += 1;
227
+ if (!event.ok)
228
+ cell.errorCount += 1;
229
+ cell.latency.record(event.durationMs);
230
+ // Attribute the request to its STEP's requestCount under the step's START
231
+ // phase (not the request's live phase), so a post-boundary same-step request
232
+ // lands in the step's single row — endpoint/matrix above already used the
233
+ // live phase. (request:observed often arrives before step:end, so the step
234
+ // agg is created lazily here under the same start phase as step:start.)
235
+ if (event.stepId) {
236
+ const step = this.getStep(event.scenarioId, event.scenarioRefId, event.stepId, this.stepStartPhase(event.iterationId, event.stepId, this.phaseOf(event)), event.stepId);
237
+ step.requestCount += 1;
238
+ }
239
+ break;
240
+ }
241
+ case "assertion:observed": {
242
+ // Only failed assertions matter to a failure trace (they don't feed an aggregate —
243
+ // step:end already carries the per-step failed-assertion count).
244
+ if (!event.passed && event.iterationId) {
245
+ this.samples.recordAssertionFailure(event.iterationId, {
246
+ tsOffsetMs: this.offsetOf(event.ts),
247
+ ...(event.stepId !== undefined ? { stepId: event.stepId } : {}),
248
+ phase: this.phaseOf(event),
249
+ ...(event.message !== undefined ? { message: event.message } : {}),
250
+ // Presence (not value) checks so a deliberately-`undefined` operand survives.
251
+ ...("actual" in event ? { actual: event.actual, hasActual: true } : {}),
252
+ ...("expected" in event ? { expected: event.expected, hasExpected: true } : {}),
253
+ });
254
+ }
255
+ break;
256
+ }
257
+ case "producer:primaryCompleted": {
258
+ // The first primaryComplete boundary of an iteration (M5): record the
259
+ // primary-phase duration and mark the iteration as having reached it.
260
+ this.primaryBoundaries += 1;
261
+ this.primaryLatency.record(event.primaryDurationMs);
262
+ if (event.iterationId) {
263
+ this.iterHadPrimary.add(event.iterationId);
264
+ // A release request leaves the primary phase but isn't a continuation until
265
+ // granted — until then it's parked on the backlog (blockedOnBacklog).
266
+ if (event.releaseRequested)
267
+ this.pendingRelease.add(event.iterationId);
268
+ }
269
+ break;
270
+ }
271
+ case "producer:released": {
272
+ // A producer slot was freed at the boundary (M6): track release count, the
273
+ // back-pressure wait, the peak backlog, and the now-live continuation (its
274
+ // iteration is in continuation phase until its iteration:end).
275
+ this.releasedProducerSlots += 1;
276
+ this.continuationBackpressure.record(event.backpressureMs);
277
+ if (event.continuationBacklog > this.maxContinuationBacklog) {
278
+ this.maxContinuationBacklog = event.continuationBacklog;
279
+ }
280
+ if (event.iterationId) {
281
+ this.pendingRelease.delete(event.iterationId); // granted → now a continuation
282
+ this.liveContinuations.add(event.iterationId);
283
+ }
284
+ break;
285
+ }
286
+ case "producer:releaseRejected": {
287
+ // A release that wasn't granted: a duplicate signal, or rejected (backlog full
288
+ // under fail-iteration, a parked release whose drain bound expired, or one
289
+ // cancelled by the run deadline).
290
+ if (event.reason === "duplicateRelease") {
291
+ this.duplicateReleaseSignals += 1;
292
+ }
293
+ else {
294
+ this.rejectedReleaseSignals += 1;
295
+ // A deadline-rejected release still STALLED the producer (it parked on the
296
+ // full backlog until the deadline) — count that wait as back-pressure, and
297
+ // it's no longer parked. (A duplicate's original release already cleared.)
298
+ if (event.waitMs > 0)
299
+ this.continuationBackpressure.record(event.waitMs);
300
+ if (event.iterationId)
301
+ this.pendingRelease.delete(event.iterationId);
302
+ }
303
+ break;
304
+ }
305
+ // assertion:observed / log:sampled / checkpoints are handled by the sink
306
+ // (failure traces) or don't affect these aggregates.
307
+ default:
308
+ break;
309
+ }
310
+ }
311
+ /** Ms from the run start (the first event's ts) — the timeline's window axis. */
312
+ offsetOf(ts) {
313
+ return this.firstTs !== undefined ? Math.max(0, ts - this.firstTs) : 0;
314
+ }
315
+ snapshot(now) {
316
+ const at = now ?? this.lastTs;
317
+ const elapsedMs = this.firstTs !== undefined ? Math.max(0, at - this.firstTs) : 0;
318
+ const elapsedSec = elapsedMs / 1000;
319
+ return {
320
+ elapsedMs,
321
+ requestedConcurrency: this.requestedConcurrency,
322
+ primaryInFlight: this.primaryInFlightCount(),
323
+ continuationInFlight: this.liveContinuations.size,
324
+ blockedOnBacklog: this.pendingRelease.size,
325
+ primaryStarted: this.iterStarted,
326
+ // With a phase split, "primary completed" = boundaries + no-boundary successes;
327
+ // in the pure closed model (no primaryComplete) it coincides with completed
328
+ // iterations.
329
+ primaryCompleted: this.primaryBoundaries > 0 ? this.primaryCompletedCount() : this.iterCompleted,
330
+ endToEndCompleted: this.iterCompleted,
331
+ failedIterations: this.iterFailed,
332
+ throughputPerSec: elapsedSec > 0 ? this.iterCompleted / elapsedSec : 0,
333
+ transactionLatency: this.iterCompleted > 0 ? this.iterLatency.percentiles() : ZERO_PCT,
334
+ topSlowEndpoints: this.topSlowEndpoints(3, elapsedSec),
335
+ recentFailures: this.recentFailures.slice(-5),
336
+ };
337
+ }
338
+ finalize() {
339
+ const startedAtMs = this.firstTs ?? 0;
340
+ const durationMs = Math.max(0, this.lastTs - startedAtMs);
341
+ const elapsedSec = durationMs / 1000;
342
+ const endpoints = this.endpointSummaries(elapsedSec);
343
+ const anyHeuristicEndpoint = endpoints.some((e) => e.routeKeyHeuristic);
344
+ return {
345
+ schemaVersion: "glubean.load.v1",
346
+ runnerId: this.runnerId,
347
+ runMode: "load",
348
+ startedAt: new Date(startedAtMs).toISOString(),
349
+ durationMs,
350
+ source: { kind: "glubean-local", engine: "local" },
351
+ config: this.config ?? { concurrency: this.requestedConcurrency },
352
+ runtime: {
353
+ engine: "local",
354
+ processModel: "single-process-async-producer-slot",
355
+ // A configured think-time makes this a paced closed run, not back-to-back.
356
+ executionModel: this.config?.pacing?.thinkTimeMs !== undefined ? "closed-paced" : "closed-back-to-back",
357
+ // Producer release ("producer-released") wins; else a primaryComplete
358
+ // boundary is a MEASURED closed run (phase split); else plain closed.
359
+ slotModel: this.releasedProducerSlots > 0
360
+ ? "producer-released"
361
+ : this.primaryBoundaries > 0
362
+ ? "end-to-end-measured"
363
+ : "end-to-end",
364
+ requestedConcurrency: this.requestedConcurrency,
365
+ // Same reducer state as snapshot(): an interrupted run (abort/crash with
366
+ // started-but-not-ended iterations) shows its in-flight count, not 0.
367
+ primaryInFlight: this.primaryInFlightCount(),
368
+ // Released iterations still in their continuation phase at finalize (an
369
+ // interrupted run; a clean run drains to 0). The orchestrator may raise this
370
+ // for drain-timeout aborts whose tails it abandoned.
371
+ continuationInFlight: this.liveContinuations.size,
372
+ // Producers still parked on a full backlog at finalize (an interrupted run;
373
+ // a clean run has none).
374
+ blockedOnBacklog: this.pendingRelease.size,
375
+ feederGuarantee: "single-node",
376
+ sampleGranularity: "event",
377
+ attribution: this.attribution(anyHeuristicEndpoint),
378
+ percentileSource: "glubean-reducer",
379
+ ...(this.crash ? { crash: this.crash } : {}),
380
+ },
381
+ summary: {
382
+ // M4 evaluates thresholds and refines pass; here pass = "didn't crash".
383
+ pass: this.endReason !== "crash" && this.crash === undefined,
384
+ totalIterations: this.iterCompleted,
385
+ successfulIterations: this.iterSucceeded,
386
+ failedIterations: this.iterFailed,
387
+ errorRate: rate(this.iterFailed, this.iterCompleted),
388
+ throughputPerSec: elapsedSec > 0 ? this.iterCompleted / elapsedSec : 0,
389
+ latency: this.iterCompleted > 0 ? this.iterLatency.percentiles() : ZERO_PCT,
390
+ ...(this.iterCompleted > 0 ? { latencyDistribution: this.iterLatency.distribution(LATENCY_LADDER) } : {}),
391
+ // Phase split (M5): present only once an iteration reached a primaryComplete
392
+ // boundary. The top-level compat fields above stay end-to-end; `primary` /
393
+ // `endToEnd` expose the split (primary = up to the boundary).
394
+ ...(this.primaryBoundaries > 0
395
+ ? { primary: this.primaryPhaseSummary(elapsedSec), endToEnd: this.endToEndPhaseSummary(elapsedSec) }
396
+ : {}),
397
+ // Continuation (M6): present once producer release is attempted.
398
+ ...(this.releaseUsed() ? { continuation: this.continuationSummary() } : {}),
399
+ thresholds: [],
400
+ },
401
+ scenarios: this.scenarioSummaries(),
402
+ steps: this.stepSummaries(),
403
+ endpoints,
404
+ matrix: this.matrixSummaries(),
405
+ timeline: this.timeline.finalize(durationMs),
406
+ samples: this.samples.finalize(),
407
+ };
408
+ }
409
+ // ── aggregate accessors ────────────────────────────────────────────────
410
+ phaseOf(event) {
411
+ return event.phase ?? "primary";
412
+ }
413
+ /** Record the phase a step started in for this iteration (from step:start). */
414
+ rememberStepPhase(iterationId, stepId, phase) {
415
+ if (!iterationId)
416
+ return;
417
+ let m = this.iterStepPhase.get(iterationId);
418
+ if (!m) {
419
+ m = new Map();
420
+ this.iterStepPhase.set(iterationId, m);
421
+ }
422
+ m.set(stepId, phase);
423
+ }
424
+ /** The phase a step started in this iteration (falls back to `live` for a SKIPPED
425
+ * leaf, which emits no step:start, or an untracked iteration). */
426
+ stepStartPhase(iterationId, stepId, live) {
427
+ if (!iterationId)
428
+ return live;
429
+ return this.iterStepPhase.get(iterationId)?.get(stepId) ?? live;
430
+ }
431
+ scenarioKey(scenarioId, scenarioRefId) {
432
+ return `${scenarioId}${SEP}${scenarioRefId ?? ""}`;
433
+ }
434
+ scenarioAgg(scenarioId, scenarioRefId) {
435
+ if (!scenarioId)
436
+ return undefined;
437
+ const key = this.scenarioKey(scenarioId, scenarioRefId);
438
+ let agg = this.scenarios.get(key);
439
+ if (!agg) {
440
+ agg = {
441
+ scenarioId,
442
+ ...(scenarioRefId !== undefined ? { scenarioRefId } : {}),
443
+ iterations: 0,
444
+ successful: 0,
445
+ failed: 0,
446
+ latency: new LoadHistogram(),
447
+ };
448
+ this.scenarios.set(key, agg);
449
+ }
450
+ return agg;
451
+ }
452
+ // Steps are keyed by (stepId, the phase the invocation STARTED in). A step that
453
+ // spans the boundary stays ONE row — every event of that invocation resolves to
454
+ // its captured start phase (`iterStepPhase`), so a post-boundary same-step request
455
+ // can't fork a phantom row — while the SAME stepId starting in different phases
456
+ // across iterations (a conditional boundary) correctly splits into two rows.
457
+ // Endpoints / matrix still split by the request's LIVE phase, so post-boundary
458
+ // requests show as continuation.
459
+ stepKey(scenarioId, scenarioRefId, stepId, phase) {
460
+ return `${scenarioId ?? ""}${SEP}${scenarioRefId ?? ""}${SEP}${stepId}${SEP}${phase}`;
461
+ }
462
+ getStep(scenarioId, scenarioRefId, stepId, phase, stepName) {
463
+ const key = this.stepKey(scenarioId, scenarioRefId, stepId, phase);
464
+ let agg = this.steps.get(key);
465
+ if (!agg) {
466
+ agg = {
467
+ scenarioId: scenarioId ?? "",
468
+ ...(scenarioRefId !== undefined ? { scenarioRefId } : {}),
469
+ stepId,
470
+ stepName,
471
+ phase, // the phase of the step's FIRST event (its start)
472
+ invocationCount: 0,
473
+ skippedCount: 0,
474
+ assertionFailureCount: 0,
475
+ errorCount: 0,
476
+ requestCount: 0,
477
+ latency: new LoadHistogram(),
478
+ };
479
+ this.steps.set(key, agg);
480
+ }
481
+ return agg;
482
+ }
483
+ endpointAgg(event) {
484
+ const phase = this.phaseOf(event); // normalize omitted phase to "primary" (matches steps)
485
+ const key = `${event.routeKey}${SEP}${phase}`;
486
+ let agg = this.endpoints.get(key);
487
+ if (!agg) {
488
+ agg = {
489
+ routeKey: event.routeKey,
490
+ phase,
491
+ routeKeySource: event.routeKeySource,
492
+ routeKeyHeuristic: event.routeKeyHeuristic,
493
+ method: event.method,
494
+ requestCount: 0,
495
+ errorCount: 0,
496
+ statusCounts: {},
497
+ latency: new LoadHistogram(),
498
+ };
499
+ this.endpoints.set(key, agg);
500
+ }
501
+ // If any request for this routeKey is heuristic, the aggregate is heuristic.
502
+ if (event.routeKeyHeuristic && !agg.routeKeyHeuristic) {
503
+ agg.routeKeyHeuristic = true;
504
+ agg.routeKeySource = event.routeKeySource;
505
+ }
506
+ return agg;
507
+ }
508
+ matrixAgg(event) {
509
+ const phase = this.phaseOf(event); // normalize omitted phase to "primary" (matches steps)
510
+ const key = `${event.scenarioId ?? ""}${SEP}${event.scenarioRefId ?? ""}${SEP}${event.stepId ?? ""}${SEP}${event.routeKey}${SEP}${phase}`;
511
+ let agg = this.matrix.get(key);
512
+ if (!agg) {
513
+ agg = {
514
+ scenarioId: event.scenarioId ?? "",
515
+ ...(event.scenarioRefId !== undefined ? { scenarioRefId: event.scenarioRefId } : {}),
516
+ ...(event.stepId !== undefined ? { stepId: event.stepId } : {}),
517
+ phase,
518
+ routeKey: event.routeKey,
519
+ routeKeySource: event.routeKeySource,
520
+ routeKeyHeuristic: event.routeKeyHeuristic,
521
+ requestCount: 0,
522
+ errorCount: 0,
523
+ latency: new LoadHistogram(),
524
+ };
525
+ this.matrix.set(key, agg);
526
+ }
527
+ if (event.routeKeyHeuristic && !agg.routeKeyHeuristic) {
528
+ agg.routeKeyHeuristic = true;
529
+ agg.routeKeySource = event.routeKeySource;
530
+ }
531
+ return agg;
532
+ }
533
+ pushFailure(f) {
534
+ this.recentFailures.push(f);
535
+ if (this.recentFailures.length > 100)
536
+ this.recentFailures.shift();
537
+ }
538
+ // ── summary builders ───────────────────────────────────────────────────
539
+ /** Iterations still in their primary phase: started, not yet at a boundary, and
540
+ * not ended before reaching one. Without any boundary this is the ordinary
541
+ * end-to-end in-flight count (started − completed). */
542
+ primaryInFlightCount() {
543
+ return Math.max(0, this.iterStarted - this.primaryBoundaries - this.endedWithoutBoundary);
544
+ }
545
+ /** Iterations that COMPLETED their primary phase: those that hit the boundary,
546
+ * plus no-boundary successes (whose primary phase is the whole iteration). Keeps
547
+ * `started = completed + failedBeforeRelease + inFlightAtEnd` consistent. */
548
+ primaryCompletedCount() {
549
+ return this.primaryBoundaries + (this.endedWithoutBoundary - this.failedBeforePrimary);
550
+ }
551
+ /** Primary-phase aggregate (producer side) — present only with a boundary. */
552
+ primaryPhaseSummary(elapsedSec) {
553
+ const completed = this.primaryCompletedCount();
554
+ return {
555
+ started: this.iterStarted,
556
+ completed,
557
+ failedBeforeRelease: this.failedBeforePrimary,
558
+ throughputPerSec: elapsedSec > 0 ? completed / elapsedSec : 0,
559
+ latency: this.primaryLatency.count > 0 ? this.primaryLatency.percentiles() : ZERO_PCT,
560
+ };
561
+ }
562
+ /** End-to-end aggregate (full logical iteration) — the structured form of the
563
+ * top-level compat fields, surfaced alongside `primary` when the split exists. */
564
+ endToEndPhaseSummary(elapsedSec) {
565
+ return {
566
+ started: this.iterStarted,
567
+ completed: this.iterCompleted,
568
+ successful: this.iterSucceeded,
569
+ failed: this.iterFailed,
570
+ inFlightAtEnd: Math.max(0, this.iterStarted - this.iterCompleted),
571
+ errorRate: rate(this.iterFailed, this.iterCompleted),
572
+ throughputPerSec: elapsedSec > 0 ? this.iterCompleted / elapsedSec : 0,
573
+ latency: this.iterCompleted > 0 ? this.iterLatency.percentiles() : ZERO_PCT,
574
+ };
575
+ }
576
+ /** Whether producer release was attempted at all (release granted, rejected, a
577
+ * duplicate, or still parked on the backlog at finalize) — gates the continuation
578
+ * summary's presence so an interrupted run with a parked release still reports it. */
579
+ releaseUsed() {
580
+ return (this.releasedProducerSlots > 0 ||
581
+ this.rejectedReleaseSignals > 0 ||
582
+ this.duplicateReleaseSignals > 0 ||
583
+ this.pendingRelease.size > 0);
584
+ }
585
+ /** Continuation (bounded-open) subsystem summary (M6). A finalized run has drained
586
+ * its continuations, so live counts (backlog / active / blockedOnBacklog) are 0;
587
+ * the cumulative release / back-pressure / coverage figures are what matter. */
588
+ continuationSummary() {
589
+ const hasBackpressure = this.continuationBackpressure.count > 0;
590
+ const waits = hasBackpressure ? this.continuationBackpressure.percentiles() : undefined;
591
+ const live = this.liveContinuations.size; // in-flight at finalize (0 for a clean run)
592
+ return {
593
+ backlog: live,
594
+ maxBacklog: this.maxContinuationBacklog,
595
+ // Every in-flight continuation is concurrently scheduled in this single-process
596
+ // model, so peak concurrency coincides with peak backlog.
597
+ maxConcurrent: this.maxContinuationBacklog,
598
+ active: live,
599
+ ...(waits ? { queueWaitMs: waits, backpressureMs: waits } : {}),
600
+ releasedProducerSlots: this.releasedProducerSlots,
601
+ // Coverage: how many iterations reached a boundary, and of those how many
602
+ // actually released — a low value flags branch/error paths that skipped it.
603
+ primaryBoundaryCoverage: this.iterStarted > 0 ? this.primaryBoundaries / this.iterStarted : 0,
604
+ releaseCoverage: this.primaryBoundaries > 0 ? this.releasedProducerSlots / this.primaryBoundaries : 0,
605
+ duplicateReleaseSignals: this.duplicateReleaseSignals,
606
+ rejectedReleaseSignals: this.rejectedReleaseSignals,
607
+ abortedByDrainTimeout: 0, // drain-timeout abort lands in M6-d
608
+ };
609
+ }
610
+ attribution(anyHeuristicEndpoint) {
611
+ const canonical = "canonical";
612
+ return {
613
+ scenario: canonical,
614
+ step: canonical,
615
+ endpoint: anyHeuristicEndpoint ? "heuristic" : canonical,
616
+ phase: canonical,
617
+ iteration: canonical,
618
+ failureTrace: canonical,
619
+ };
620
+ }
621
+ stepSummary(agg) {
622
+ return {
623
+ scenarioId: agg.scenarioId,
624
+ // Carry the mix entry id so two entries referencing the SAME scenario aren't
625
+ // indistinguishable in the flat `artifact.steps` (matches the scenario summaries
626
+ // + matrix, which already carry it). Omitted for a single scenario.
627
+ ...(agg.scenarioRefId !== undefined ? { scenarioRefId: agg.scenarioRefId } : {}),
628
+ stepId: agg.stepId,
629
+ stepName: agg.stepName,
630
+ ...(agg.groupId !== undefined ? { groupId: agg.groupId } : {}),
631
+ phase: agg.phase,
632
+ invocationCount: agg.invocationCount,
633
+ skippedCount: agg.skippedCount,
634
+ assertionFailureCount: agg.assertionFailureCount,
635
+ errorCount: agg.errorCount,
636
+ // Error rate is over EXECUTED invocations — skipped steps aren't failures.
637
+ errorRate: rate(agg.errorCount, agg.invocationCount - agg.skippedCount),
638
+ latency: agg.latency.count > 0 ? agg.latency.percentiles() : ZERO_PCT,
639
+ requestCount: agg.requestCount,
640
+ };
641
+ }
642
+ stepSummaries() {
643
+ return [...this.steps.values()].map((s) => this.stepSummary(s));
644
+ }
645
+ scenarioSummaries() {
646
+ const stepsByScenario = new Map();
647
+ for (const s of this.steps.values()) {
648
+ const k = this.scenarioKey(s.scenarioId, s.scenarioRefId);
649
+ const list = stepsByScenario.get(k) ?? [];
650
+ list.push(this.stepSummary(s));
651
+ stepsByScenario.set(k, list);
652
+ }
653
+ return [...this.scenarios.values()].map((sc) => ({
654
+ scenarioId: sc.scenarioId,
655
+ ...(sc.scenarioRefId !== undefined ? { scenarioRefId: sc.scenarioRefId } : {}),
656
+ iterations: sc.iterations,
657
+ successfulIterations: sc.successful,
658
+ failedIterations: sc.failed,
659
+ errorRate: rate(sc.failed, sc.iterations),
660
+ latency: sc.latency.count > 0 ? sc.latency.percentiles() : ZERO_PCT,
661
+ steps: stepsByScenario.get(this.scenarioKey(sc.scenarioId, sc.scenarioRefId)) ?? [],
662
+ }));
663
+ }
664
+ endpointSummaries(elapsedSec) {
665
+ return [...this.endpoints.values()].map((e) => ({
666
+ routeKey: e.routeKey,
667
+ ...(e.phase !== undefined ? { phase: e.phase } : {}),
668
+ routeKeySource: e.routeKeySource,
669
+ routeKeyHeuristic: e.routeKeyHeuristic,
670
+ ...(e.method !== undefined ? { method: e.method } : {}),
671
+ requestCount: e.requestCount,
672
+ errorCount: e.errorCount,
673
+ errorRate: rate(e.errorCount, e.requestCount),
674
+ statusCounts: e.statusCounts,
675
+ latency: e.latency.count > 0 ? e.latency.percentiles() : ZERO_PCT,
676
+ ...(e.latency.count > 0 ? { latencyDistribution: e.latency.distribution(LATENCY_LADDER) } : {}),
677
+ throughputPerSec: elapsedSec > 0 ? e.requestCount / elapsedSec : 0,
678
+ }));
679
+ }
680
+ matrixSummaries() {
681
+ // Resolve a matrix cell's stepName by stepId regardless of phase: the cell's
682
+ // phase is the request's LIVE phase, but the step agg is keyed by its start
683
+ // phase, so a post-boundary request's cell would otherwise miss its step row.
684
+ const stepNameById = new Map();
685
+ for (const s of this.steps.values()) {
686
+ stepNameById.set(`${this.scenarioKey(s.scenarioId, s.scenarioRefId)}${SEP}${s.stepId}`, s.stepName);
687
+ }
688
+ return [...this.matrix.values()].map((m) => {
689
+ const stepName = m.stepId !== undefined
690
+ ? stepNameById.get(`${this.scenarioKey(m.scenarioId, m.scenarioRefId)}${SEP}${m.stepId}`)
691
+ : undefined;
692
+ return {
693
+ scenarioId: m.scenarioId,
694
+ ...(m.scenarioRefId !== undefined ? { scenarioRefId: m.scenarioRefId } : {}),
695
+ ...(m.stepId !== undefined ? { stepId: m.stepId } : {}),
696
+ ...(stepName !== undefined ? { stepName } : {}),
697
+ ...(m.phase !== undefined ? { phase: m.phase } : {}),
698
+ routeKey: m.routeKey,
699
+ routeKeySource: m.routeKeySource,
700
+ routeKeyHeuristic: m.routeKeyHeuristic,
701
+ requestCount: m.requestCount,
702
+ errorRate: rate(m.errorCount, m.requestCount),
703
+ latency: m.latency.count > 0 ? m.latency.percentiles() : ZERO_PCT,
704
+ };
705
+ });
706
+ }
707
+ topSlowEndpoints(n, elapsedSec) {
708
+ return this.endpointSummaries(elapsedSec)
709
+ .sort((a, b) => b.latency.p95 - a.latency.p95)
710
+ .slice(0, n);
711
+ }
712
+ }
713
+ /** Create a fresh streaming load reducer. `reportCaps` bounds the failure-trace /
714
+ * slow-transaction samples (from the plan's `report` config; 0 disables a type). */
715
+ export function createLoadReducer(reportCaps) {
716
+ return new LoadReducerImpl(reportCaps);
717
+ }
718
+ //# sourceMappingURL=reducer.js.map