@fedify/cli 2.3.0-dev.1281 → 2.3.0-dev.1336

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.
@@ -9,10 +9,10 @@ import { systemClock } from "./clock.js";
9
9
  * @param clock The clock (overridable for tests); defaults to the system clock.
10
10
  * @returns The recorded samples and run metadata.
11
11
  */
12
- function runLoad(plan, send, clock = systemClock()) {
13
- return plan.load.kind === "open" ? runOpenLoop(plan, plan.load, send, clock) : runClosedLoop(plan, plan.load, send, clock);
12
+ function runLoad(plan, send, clock = systemClock(), signal) {
13
+ return plan.load.kind === "open" ? runOpenLoop(plan, plan.load, send, clock, signal) : runClosedLoop(plan, plan.load, send, clock, signal);
14
14
  }
15
- async function runOpenLoop(plan, load, send, clock) {
15
+ async function runOpenLoop(plan, load, send, clock, signal) {
16
16
  const arrivals = scheduleArrivals({
17
17
  ratePerSec: load.ratePerSec,
18
18
  durationMs: plan.durationMs,
@@ -25,8 +25,13 @@ async function runOpenLoop(plan, load, send, clock) {
25
25
  const start = clock.now();
26
26
  const active = /* @__PURE__ */ new Set();
27
27
  for (const offset of arrivals) {
28
- await clock.sleepUntil(start + offset);
29
- if (await slots.acquire()) saturated = true;
28
+ throwIfAborted(signal);
29
+ await clock.sleepUntil(start + offset, signal);
30
+ if (await slots.acquire(signal)) saturated = true;
31
+ if (signal?.aborted) {
32
+ slots.release();
33
+ throw abortReason(signal);
34
+ }
30
35
  const dispatched = dispatch(send, offset, start, plan.warmupMs, clock, samples).finally(() => {
31
36
  slots.release();
32
37
  active.delete(dispatched);
@@ -40,7 +45,7 @@ async function runOpenLoop(plan, load, send, clock) {
40
45
  wallDurationMs: clock.now() - start
41
46
  };
42
47
  }
43
- async function runClosedLoop(plan, load, send, clock) {
48
+ async function runClosedLoop(plan, load, send, clock, signal) {
44
49
  const samples = [];
45
50
  const slots = createSemaphore(load.maxInFlight);
46
51
  let saturated = false;
@@ -48,7 +53,12 @@ async function runClosedLoop(plan, load, send, clock) {
48
53
  const deadline = start + plan.durationMs;
49
54
  async function worker() {
50
55
  while (clock.now() < deadline) {
51
- if (await slots.acquire()) saturated = true;
56
+ throwIfAborted(signal);
57
+ if (await slots.acquire(signal)) saturated = true;
58
+ if (signal?.aborted) {
59
+ slots.release();
60
+ throw abortReason(signal);
61
+ }
52
62
  if (clock.now() >= deadline) {
53
63
  slots.release();
54
64
  break;
@@ -94,12 +104,29 @@ function createSemaphore(max) {
94
104
  let count = 0;
95
105
  const queue = [];
96
106
  return {
97
- acquire() {
107
+ acquire(signal) {
108
+ throwIfAborted(signal);
98
109
  if (count < max) {
99
110
  count++;
100
111
  return Promise.resolve(false);
101
112
  }
102
- return new Promise((resolve) => queue.push(() => resolve(true)));
113
+ return new Promise((resolve, reject) => {
114
+ const waiter = () => {
115
+ cleanup();
116
+ resolve(true);
117
+ };
118
+ const onAbort = () => {
119
+ const index = queue.indexOf(waiter);
120
+ if (index >= 0) queue.splice(index, 1);
121
+ cleanup();
122
+ reject(abortReason(signal));
123
+ };
124
+ const cleanup = () => {
125
+ signal?.removeEventListener("abort", onAbort);
126
+ };
127
+ signal?.addEventListener("abort", onAbort, { once: true });
128
+ queue.push(waiter);
129
+ });
103
130
  },
104
131
  release() {
105
132
  const next = queue.shift();
@@ -108,5 +135,11 @@ function createSemaphore(max) {
108
135
  }
109
136
  };
110
137
  }
138
+ function throwIfAborted(signal) {
139
+ if (signal?.aborted) throw abortReason(signal);
140
+ }
141
+ function abortReason(signal) {
142
+ return signal.reason ?? /* @__PURE__ */ new Error("Benchmark load aborted.");
143
+ }
111
144
  //#endregion
112
145
  export { runLoad };
@@ -36,9 +36,13 @@ function parseServerSnapshot(snapshot) {
36
36
  const values = depth.dataPoints.map((p) => p.value).filter(isFiniteNumber);
37
37
  if (values.length > 0) queueDepthMax = Math.max(...values);
38
38
  }
39
+ const queueTasks = parseQueueTasks(metrics);
40
+ const deliveryPermanentFailures = sumMetric(metrics, "activitypub.delivery.permanent_failure");
39
41
  return {
40
42
  signature,
41
- queueDepthMax
43
+ queueDepthMax,
44
+ ...queueTasks == null ? {} : { queueTasks },
45
+ ...deliveryPermanentFailures == null ? {} : { deliveryPermanentFailures }
42
46
  };
43
47
  } catch {
44
48
  return null;
@@ -56,9 +60,13 @@ function parseServerSnapshot(snapshot) {
56
60
  * @returns The windowed snapshot.
57
61
  */
58
62
  function diffSnapshots(baseline, end) {
63
+ const queueTasks = diffQueueTasks(baseline.queueTasks ?? null, end.queueTasks ?? null);
64
+ const deliveryPermanentFailures = diffCounter(baseline.deliveryPermanentFailures ?? null, end.deliveryPermanentFailures ?? null);
59
65
  return {
60
66
  signature: diffHistogram(baseline.signature, end.signature),
61
- queueDepthMax: end.queueDepthMax
67
+ queueDepthMax: end.queueDepthMax,
68
+ ...queueTasks == null ? {} : { queueTasks },
69
+ ...deliveryPermanentFailures == null ? {} : { deliveryPermanentFailures }
62
70
  };
63
71
  }
64
72
  /**
@@ -95,6 +103,21 @@ async function fetchServerSnapshot(target, fetchImpl = fetch) {
95
103
  return null;
96
104
  }
97
105
  }
106
+ /**
107
+ * Returns the remaining queue task backlog represented by a diffed snapshot.
108
+ * @param snapshot The server snapshot to inspect, usually already diffed
109
+ * against a baseline.
110
+ * @param baselineRemaining Queue tasks that were already outstanding when the
111
+ * diff baseline was taken. These must drain before diffed completions can be
112
+ * attributed to newly enqueued tasks.
113
+ * @returns `Math.max(0, baselineRemaining + enqueued - completed - failed)`,
114
+ * or `null` when the snapshot has no queue task counters.
115
+ */
116
+ function queueTaskRemaining(snapshot, baselineRemaining = 0) {
117
+ if (snapshot?.queueTasks == null) return null;
118
+ const { enqueued, completed, failed } = snapshot.queueTasks;
119
+ return Math.max(0, baselineRemaining + enqueued - completed - failed);
120
+ }
98
121
  function isFiniteNumber(value) {
99
122
  return typeof value === "number" && Number.isFinite(value);
100
123
  }
@@ -125,6 +148,28 @@ function mergeHistogram(dataPoints) {
125
148
  counts
126
149
  } : null;
127
150
  }
151
+ function parseQueueTasks(metrics) {
152
+ const enqueued = sumMetric(metrics, "fedify.queue.task.enqueued");
153
+ const completed = sumMetric(metrics, "fedify.queue.task.completed");
154
+ const failed = sumMetric(metrics, "fedify.queue.task.failed");
155
+ return enqueued == null && completed == null && failed == null ? null : {
156
+ enqueued: enqueued ?? 0,
157
+ completed: completed ?? 0,
158
+ failed: failed ?? 0
159
+ };
160
+ }
161
+ function sumMetric(metrics, name) {
162
+ let total = 0;
163
+ let found = false;
164
+ for (const metric of metrics) {
165
+ if (metric.name !== name || !Array.isArray(metric.dataPoints)) continue;
166
+ for (const point of metric.dataPoints) if (isRecord(point) && isFiniteNumber(point["value"])) {
167
+ total += point["value"];
168
+ found = true;
169
+ }
170
+ }
171
+ return found ? total : null;
172
+ }
128
173
  function diffHistogram(baseline, end) {
129
174
  if (end == null) return null;
130
175
  if (baseline == null) return end;
@@ -135,9 +180,26 @@ function diffHistogram(baseline, end) {
135
180
  counts
136
181
  };
137
182
  }
183
+ function diffQueueTasks(baseline, end) {
184
+ if (end == null) return null;
185
+ if (baseline == null) return end;
186
+ return {
187
+ enqueued: Math.max(0, end.enqueued - baseline.enqueued),
188
+ completed: Math.max(0, end.completed - baseline.completed),
189
+ failed: Math.max(0, end.failed - baseline.failed)
190
+ };
191
+ }
192
+ function diffCounter(baseline, end) {
193
+ if (end == null) return null;
194
+ if (baseline == null) return end;
195
+ return Math.max(0, end - baseline);
196
+ }
138
197
  function histogramsCompatible(a, b) {
139
198
  return a.boundaries.length === b.boundaries.length && a.counts.length === b.counts.length && a.boundaries.every((boundary, i) => boundary === b.boundaries[i]);
140
199
  }
200
+ function isRecord(value) {
201
+ return value != null && typeof value === "object" && !Array.isArray(value);
202
+ }
141
203
  function histogramPercentile(histogram, p) {
142
204
  const { boundaries, counts } = histogram;
143
205
  const total = counts.reduce((sum, n) => sum + n, 0);
@@ -151,4 +213,4 @@ function histogramPercentile(histogram, p) {
151
213
  return boundaries[boundaries.length - 1] ?? 0;
152
214
  }
153
215
  //#endregion
154
- export { diffSnapshots, fetchServerSnapshot, snapshotToMetrics };
216
+ export { diffSnapshots, fetchServerSnapshot, queueTaskRemaining, snapshotToMetrics };
package/dist/bench/mod.js CHANGED
@@ -1,4 +1,11 @@
1
1
  import "@js-temporal/polyfill";
2
- import "./action.js";
2
+ import runBench$1 from "./action.js";
3
+ import { runBenchCompare } from "./compare.js";
3
4
  import "./command.js";
4
- export {};
5
+ //#region src/bench/mod.ts
6
+ /** Runs a parsed benchmark command. */
7
+ function runBench(command) {
8
+ return command.mode === "compare" ? runBenchCompare(command) : runBench$1(command);
9
+ }
10
+ //#endregion
11
+ export { runBench };
@@ -32,6 +32,7 @@ function renderScenario(scenario) {
32
32
  lines.push(`| Requests | ${formatNumber(r.total)} |`);
33
33
  lines.push(`| Success rate | ${formatPercent(r.successRate)} |`);
34
34
  lines.push(`| Throughput | ${formatNumber(scenario.throughputPerSec)}/s |`);
35
+ if (scenario.deliveryThroughputPerSec != null) lines.push(`| Delivery throughput | ${formatNumber(scenario.deliveryThroughputPerSec)}/s |`);
35
36
  const l = scenario.client.latencyMs;
36
37
  lines.push(`| Latency p50 | ${formatNumber(l.p50)}ms |`);
37
38
  lines.push(`| Latency p95 | ${formatNumber(l.p95)}ms |`);
@@ -28,6 +28,7 @@ function renderScenario(scenario) {
28
28
  const r = scenario.requests;
29
29
  lines.push(` Requests: ${formatNumber(r.total)} (ok ${formatNumber(r.ok)}, failed ${formatNumber(r.failed)}, success ${formatPercent(r.successRate)})`);
30
30
  lines.push(` Throughput: ${formatNumber(scenario.throughputPerSec)} req/s`);
31
+ if (scenario.deliveryThroughputPerSec != null) lines.push(` Delivery throughput: ${formatNumber(scenario.deliveryThroughputPerSec)} deliveries/s`);
31
32
  const l = scenario.client.latencyMs;
32
33
  lines.push(` Client latency (ms): p50 ${formatNumber(l.p50)} p95 ${formatNumber(l.p95)} p99 ${formatNumber(l.p99)} mean ${formatNumber(l.mean)} max ${formatNumber(l.max)}`);
33
34
  if (scenario.server?.signatureVerificationMs != null) lines.push(` Server signature verification (ms): ${describePartial(scenario.server.signatureVerificationMs.overall)}`);
@@ -23,19 +23,25 @@ import { cpus } from "node:os";
23
23
  * @returns The assembled scenario result.
24
24
  */
25
25
  function buildScenarioResult(scenario, measurement) {
26
- const { results, passed } = evaluateExpect(scenario.expect, measurement);
26
+ const measurements = Array.isArray(measurement) ? measurement : [measurement];
27
+ if (measurements.length < 1) throw new RangeError("At least one scenario measurement is required.");
28
+ const aggregate = measurements.length === 1 ? measurements[0] : aggregateMeasurements(measurements);
29
+ const { results, passed } = evaluateExpect(scenario.expect, aggregate);
27
30
  return {
28
31
  name: scenario.name,
29
32
  type: scenario.type,
30
33
  load: loadSummary(scenario),
31
- requests: measurement.requests,
32
- throughputPerSec: measurement.throughputPerSec,
33
- client: measurement.client,
34
- server: measurement.server,
35
- errors: measurement.errors,
34
+ requests: aggregate.requests,
35
+ throughputPerSec: aggregate.throughputPerSec,
36
+ ...aggregate.deliveryThroughputPerSec == null ? {} : { deliveryThroughputPerSec: aggregate.deliveryThroughputPerSec },
37
+ client: aggregate.client,
38
+ server: aggregate.server,
39
+ errors: aggregate.errors,
36
40
  expectations: results,
37
- passed: passed && measurement.requests.total > 0,
38
- ...measurement.histogram ? { histogram: measurement.histogram } : {}
41
+ passed: passed && measurements.every((m) => m.requests.total > 0),
42
+ runCount: measurements.length,
43
+ ...measurements.length > 1 ? { runs: measurements.map((m, index) => runResult(index + 1, m)) } : {},
44
+ ...aggregate.histogram ? { histogram: aggregate.histogram } : {}
39
45
  };
40
46
  }
41
47
  /**
@@ -47,7 +53,7 @@ function buildScenarioResult(scenario, measurement) {
47
53
  function buildReport(input) {
48
54
  return {
49
55
  $schema: REPORT_SCHEMA_ID,
50
- schemaVersion: 1,
56
+ schemaVersion: 3,
51
57
  tool: {
52
58
  name: "@fedify/cli",
53
59
  version
@@ -61,6 +67,123 @@ function buildReport(input) {
61
67
  scenarios: input.scenarios
62
68
  };
63
69
  }
70
+ function aggregateMeasurements(measurements) {
71
+ const errors = sumErrorBuckets(measurements.flatMap((m) => m.errors));
72
+ const total = sum(measurements.map((m) => m.requests.total));
73
+ const ok = sum(measurements.map((m) => m.requests.ok));
74
+ const failed = sum(measurements.map((m) => m.requests.failed));
75
+ const delivery = medianPresent(measurements.map((m) => m.deliveryThroughputPerSec));
76
+ return {
77
+ requests: {
78
+ total,
79
+ ok,
80
+ failed,
81
+ successRate: Math.min(...measurements.map((m) => m.requests.successRate))
82
+ },
83
+ throughputPerSec: median(measurements.map((m) => m.throughputPerSec)),
84
+ ...delivery == null ? {} : { deliveryThroughputPerSec: delivery },
85
+ client: { latencyMs: {
86
+ p50: median(measurements.map((m) => m.client.latencyMs.p50)),
87
+ p95: median(measurements.map((m) => m.client.latencyMs.p95)),
88
+ p99: median(measurements.map((m) => m.client.latencyMs.p99)),
89
+ mean: median(measurements.map((m) => m.client.latencyMs.mean)),
90
+ max: median(measurements.map((m) => m.client.latencyMs.max))
91
+ } },
92
+ server: aggregateServer(measurements.map((m) => m.server)),
93
+ errors
94
+ };
95
+ }
96
+ function runResult(run, measurement) {
97
+ return {
98
+ run,
99
+ requests: measurement.requests,
100
+ throughputPerSec: measurement.throughputPerSec,
101
+ ...measurement.deliveryThroughputPerSec == null ? {} : { deliveryThroughputPerSec: measurement.deliveryThroughputPerSec },
102
+ client: measurement.client,
103
+ server: measurement.server,
104
+ errors: measurement.errors,
105
+ ...measurement.histogram ? { histogram: measurement.histogram } : {}
106
+ };
107
+ }
108
+ function aggregateServer(servers) {
109
+ const present = servers.filter((s) => s != null);
110
+ if (present.length !== servers.length) return null;
111
+ const signature = aggregateSignatureVerification(present);
112
+ const queue = aggregateQueue(present);
113
+ return {
114
+ ...signature == null ? {} : { signatureVerificationMs: signature },
115
+ ...queue == null ? {} : { queue }
116
+ };
117
+ }
118
+ function aggregateSignatureVerification(servers) {
119
+ const values = servers.map((s) => s.signatureVerificationMs).filter((s) => s != null);
120
+ if (values.length !== servers.length) return null;
121
+ const standards = /* @__PURE__ */ new Set();
122
+ for (const value of values) for (const key of Object.keys(value.byStandard ?? {})) standards.add(key);
123
+ const byStandard = {};
124
+ for (const standard of standards) byStandard[standard] = aggregatePartial(values.map((v) => v.byStandard?.[standard]), "present");
125
+ return {
126
+ overall: aggregatePartial(values.map((v) => v.overall)),
127
+ ...Object.keys(byStandard).length < 1 ? {} : { byStandard }
128
+ };
129
+ }
130
+ function aggregateQueue(servers) {
131
+ const values = servers.map((s) => s.queue).filter((q) => q != null);
132
+ if (values.length !== servers.length) return null;
133
+ const drainMs = aggregatePartial(values.map((v) => v.drainMs));
134
+ const depths = values.map((v) => v.depthMax);
135
+ return {
136
+ ...hasPartial(drainMs) ? { drainMs } : {},
137
+ ...depths.every(isNumber) ? { depthMax: Math.max(...depths) } : {}
138
+ };
139
+ }
140
+ function aggregatePartial(values, mode = "complete") {
141
+ return {
142
+ ...partialField(values, "p50", mode),
143
+ ...partialField(values, "p95", mode),
144
+ ...partialField(values, "p99", mode)
145
+ };
146
+ }
147
+ function partialField(values, key, mode) {
148
+ const fieldValues = values.map((v) => v?.[key]);
149
+ if (mode === "present") {
150
+ const present = fieldValues.filter(isNumber);
151
+ return present.length < 1 ? {} : { [key]: median(present) };
152
+ }
153
+ return fieldValues.every(isNumber) ? { [key]: median(fieldValues) } : {};
154
+ }
155
+ function hasPartial(value) {
156
+ return value.p50 != null || value.p95 != null || value.p99 != null;
157
+ }
158
+ function sumErrorBuckets(errors) {
159
+ const buckets = /* @__PURE__ */ new Map();
160
+ for (const error of errors) {
161
+ const key = `${error.kind}|${error.status ?? ""}|${error.reason}`;
162
+ const previous = buckets.get(key);
163
+ buckets.set(key, {
164
+ ...error,
165
+ count: (previous?.count ?? 0) + error.count
166
+ });
167
+ }
168
+ return [...buckets.values()].sort((a, b) => b.count - a.count);
169
+ }
170
+ function medianPresent(values) {
171
+ const present = values.filter(isNumber);
172
+ return present.length < 1 ? null : median(present);
173
+ }
174
+ function median(values) {
175
+ if (values.length < 1) return 0;
176
+ const sorted = [...values].sort((a, b) => a - b);
177
+ const middle = Math.floor(sorted.length / 2);
178
+ if (sorted.length % 2 === 1) return sorted[middle];
179
+ return (sorted[middle - 1] + sorted[middle]) / 2;
180
+ }
181
+ function sum(values) {
182
+ return values.reduce((a, b) => a + b, 0);
183
+ }
184
+ function isNumber(value) {
185
+ return typeof value === "number" && Number.isFinite(value);
186
+ }
64
187
  /** Detects the current runtime environment for reproducibility metadata. */
65
188
  function detectEnvironment() {
66
189
  const g = globalThis;
@@ -78,7 +78,7 @@ function lookupValue(metrics, metric) {
78
78
  switch (metric) {
79
79
  case "successRate": return metrics.requests.successRate;
80
80
  case "throughputPerSec": return metrics.throughputPerSec;
81
- case "deliveryThroughput": return null;
81
+ case "deliveryThroughput": return metrics.deliveryThroughputPerSec ?? null;
82
82
  case "errors.total": return sumErrors(metrics.errors);
83
83
  case "errors.4xx": return sumErrors(metrics.errors, {
84
84
  min: 400,