@fedify/cli 2.3.0-dev.1299 → 2.3.0-dev.1344
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.
- package/dist/bench/action.js +189 -29
- package/dist/bench/command.js +43 -13
- package/dist/bench/compare/schema.js +16 -0
- package/dist/bench/compare.js +667 -0
- package/dist/bench/load/clock.js +20 -2
- package/dist/bench/load/generator.js +42 -9
- package/dist/bench/metrics/stats-client.js +65 -3
- package/dist/bench/mod.js +9 -2
- package/dist/bench/render/markdown.js +1 -0
- package/dist/bench/render/text.js +1 -0
- package/dist/bench/result/build.js +133 -10
- package/dist/bench/result/expect/evaluate.js +1 -1
- package/dist/bench/result/schema.js +353 -3
- package/dist/bench/safety/gate.js +4 -2
- package/dist/bench/scenario/normalize.js +1 -2
- package/dist/bench/scenario/schema.js +50 -9
- package/dist/bench/scenario/validate.js +2 -2
- package/dist/bench/scenarios/actor.js +38 -0
- package/dist/bench/scenarios/failure.js +363 -0
- package/dist/bench/scenarios/fanout.js +261 -0
- package/dist/bench/scenarios/inbox.js +4 -12
- package/dist/bench/scenarios/mixed.js +244 -0
- package/dist/bench/scenarios/object-discovery.js +211 -0
- package/dist/bench/scenarios/object.js +54 -0
- package/dist/bench/scenarios/read.js +108 -0
- package/dist/bench/scenarios/registry.js +19 -1
- package/dist/bench/scenarios/runner.js +21 -1
- package/dist/bench/scenarios/webfinger.js +1 -1
- package/dist/cache.js +1 -1
- package/dist/commands.js +110 -0
- package/dist/config.js +1 -1
- package/dist/deno.js +1 -1
- package/dist/docloader.js +1 -1
- package/dist/generate-vocab/action.js +1 -1
- package/dist/generate-vocab/command.js +5 -3
- package/dist/imagerenderer.js +2 -2
- package/dist/inbox/command.js +6 -4
- package/dist/inbox.js +4 -4
- package/dist/log.js +2 -2
- package/dist/lookup/command.js +121 -0
- package/dist/lookup.js +12 -123
- package/dist/mod.js +2 -23
- package/dist/nodeinfo.js +11 -9
- package/dist/options.js +1 -1
- package/dist/relay/command.js +6 -4
- package/dist/relay.js +2 -2
- package/dist/runner.js +69 -46
- package/dist/tempserver.js +1 -1
- package/dist/tunnel.js +6 -4
- package/dist/utils.js +5 -4
- package/dist/webfinger/action.js +1 -1
- package/dist/webfinger/command.js +6 -4
- package/dist/webfinger/lib.js +1 -1
- package/package.json +13 -12
- package/dist/generate-vocab/mod.js +0 -4
- package/dist/init/mod.js +0 -3
- package/dist/webfinger/mod.js +0 -4
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import "@js-temporal/polyfill";
|
|
2
|
+
import { LogLinearHistogram } from "../metrics/histogram.js";
|
|
3
|
+
import { actorRunner } from "./actor.js";
|
|
4
|
+
import { fanoutRunner } from "./fanout.js";
|
|
5
|
+
import { failureRunner } from "./failure.js";
|
|
6
|
+
import { inboxRunner } from "./inbox.js";
|
|
7
|
+
import { objectRunner } from "./object.js";
|
|
8
|
+
import { webfingerRunner } from "./webfinger.js";
|
|
9
|
+
//#region src/bench/scenarios/mixed.ts
|
|
10
|
+
/**
|
|
11
|
+
* The `mixed` scenario runner.
|
|
12
|
+
* @since 2.3.0
|
|
13
|
+
* @module
|
|
14
|
+
*/
|
|
15
|
+
/** The `mixed` scenario runner. */
|
|
16
|
+
const mixedRunner = {
|
|
17
|
+
validate(scenario, context) {
|
|
18
|
+
if (scenario.raw.mix == null || scenario.raw.mix.length < 1) throw new Error(`Scenario "${scenario.name}": mixed requires at least one mix entry.`);
|
|
19
|
+
for (const entry of scenario.raw.mix) if (entry.weight <= 0) throw new Error(`Scenario "${scenario.name}": mix entry ${entry.scenario} has a non-positive weight.`);
|
|
20
|
+
if (scenario.load.kind === "closed" && scenario.load.concurrency < scenario.raw.mix.length) throw new Error(`Scenario "${scenario.name}": closed-loop mixed load needs at least one concurrency slot per mix entry.`);
|
|
21
|
+
const serverExpectation = Object.keys(scenario.expect).find(isServerExpectation);
|
|
22
|
+
if (serverExpectation != null) throw new Error(`Scenario "${scenario.name}": mixed server-side expectations are not supported (${JSON.stringify(serverExpectation)}).`);
|
|
23
|
+
if (context?.scenarios != null) {
|
|
24
|
+
const children = childScenarios(scenario, context.scenarios);
|
|
25
|
+
for (const child of children) runnerForChild(child.type).validate?.(child, context);
|
|
26
|
+
validateTargetQueueObservation(scenario, children);
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
async run(context) {
|
|
30
|
+
this.validate?.(context.scenario, { scenarios: context.scenarios });
|
|
31
|
+
if (context.scenarios == null) throw new Error("The mixed scenario requires the resolved scenario list.");
|
|
32
|
+
const children = childScenarios(context.scenario, context.scenarios);
|
|
33
|
+
const fetchImpl = limitedFetch(context.fetch ?? fetch, context.scenario.load.maxInFlight);
|
|
34
|
+
const results = await Promise.allSettled(children.map((child) => runnerForChild(child.type).run({
|
|
35
|
+
...context,
|
|
36
|
+
scenario: child,
|
|
37
|
+
fetch: fetchImpl,
|
|
38
|
+
assertDestinationAllowed: (url, scenario) => context.assertDestinationAllowed?.(url, scenario ?? child),
|
|
39
|
+
assertReadDestinationAllowed: (url, scenario) => context.assertReadDestinationAllowed?.(url, scenario ?? child),
|
|
40
|
+
assertActorlessDestinationAllowed: (url, scenario) => context.assertActorlessDestinationAllowed?.(url, scenario ?? child)
|
|
41
|
+
})));
|
|
42
|
+
const rejected = results.find((result) => result.status === "rejected");
|
|
43
|
+
if (rejected != null) throw rejected.reason;
|
|
44
|
+
return mergeMeasurements(results.filter((result) => result.status === "fulfilled").map((result) => result.value));
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
function isServerExpectation(metric) {
|
|
48
|
+
return metric.startsWith("signatureVerification.") || metric.startsWith("queueDrain.");
|
|
49
|
+
}
|
|
50
|
+
function validateTargetQueueObservation(scenario, children) {
|
|
51
|
+
const observers = children.filter(observesTargetQueue);
|
|
52
|
+
if (observers.length < 1) return;
|
|
53
|
+
if (children.filter(producesTargetQueue).length <= 1) return;
|
|
54
|
+
throw new Error(`Scenario "${scenario.name}": mixed scenarios cannot run queue-observing children (${observers.map((child) => child.name).join(", ")}) concurrently with other target queue producers because target queue counters are not scoped per child.`);
|
|
55
|
+
}
|
|
56
|
+
function observesTargetQueue(scenario) {
|
|
57
|
+
return scenario.type === "fanout" || scenario.type === "failure" && faultsOf(scenario).some(isRemoteFault);
|
|
58
|
+
}
|
|
59
|
+
function producesTargetQueue(scenario) {
|
|
60
|
+
return scenario.type === "inbox" || scenario.type === "fanout" || scenario.type === "failure" && faultsOf(scenario).some(isRemoteFault);
|
|
61
|
+
}
|
|
62
|
+
function faultsOf(scenario) {
|
|
63
|
+
return scenario.faults.length < 1 ? ["remote-404"] : scenario.faults;
|
|
64
|
+
}
|
|
65
|
+
function isRemoteFault(fault) {
|
|
66
|
+
return fault === "remote-404" || fault === "remote-410" || fault === "slow-inbox" || fault === "network-error";
|
|
67
|
+
}
|
|
68
|
+
function childScenarios(scenario, scenarios) {
|
|
69
|
+
const entries = scenario.raw.mix ?? [];
|
|
70
|
+
const totalWeight = entries.reduce((sum, entry) => sum + entry.weight, 0);
|
|
71
|
+
const closedLoads = scenario.load.kind === "closed" ? scaledClosedConcurrencies(scenario.load.concurrency, entries.map((entry) => entry.weight)) : void 0;
|
|
72
|
+
return entries.map((entry, index) => {
|
|
73
|
+
const children = scenarios.filter((candidate) => candidate.name === entry.scenario);
|
|
74
|
+
const child = children[0];
|
|
75
|
+
if (child == null) throw new Error(`Scenario "${scenario.name}": unknown mixed child ${JSON.stringify(entry.scenario)}.`);
|
|
76
|
+
if (children.length > 1) throw new Error(`Scenario "${scenario.name}": ambiguous mixed child ${JSON.stringify(entry.scenario)} matches ${children.length} scenarios.`);
|
|
77
|
+
if (child.type === "mixed") throw new Error(`Scenario "${scenario.name}": nested mixed scenarios are not supported.`);
|
|
78
|
+
const load = scaledLoad(scenario.load, entry.weight, totalWeight, closedLoads?.[index]);
|
|
79
|
+
return {
|
|
80
|
+
...child,
|
|
81
|
+
name: `${scenario.name}/${child.name}`,
|
|
82
|
+
load,
|
|
83
|
+
durationMs: scenario.durationMs,
|
|
84
|
+
warmupMs: scenario.warmupMs,
|
|
85
|
+
signing: scenario.signing,
|
|
86
|
+
signatureTimeWindow: scenario.signatureTimeWindow,
|
|
87
|
+
expect: {},
|
|
88
|
+
raw: {
|
|
89
|
+
...child.raw,
|
|
90
|
+
name: `${scenario.name}/${child.name}`,
|
|
91
|
+
load: rawLoad(load),
|
|
92
|
+
duration: `${scenario.durationMs}ms`,
|
|
93
|
+
warmup: `${scenario.warmupMs}ms`,
|
|
94
|
+
signing: scenario.signing,
|
|
95
|
+
signatureTimeWindow: scenario.signatureTimeWindow,
|
|
96
|
+
expect: {}
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
function scaledLoad(load, weight, totalWeight, closedConcurrency) {
|
|
102
|
+
if (load.kind === "open") return {
|
|
103
|
+
kind: "open",
|
|
104
|
+
ratePerSec: load.ratePerSec * (weight / totalWeight),
|
|
105
|
+
arrival: load.arrival
|
|
106
|
+
};
|
|
107
|
+
return {
|
|
108
|
+
kind: "closed",
|
|
109
|
+
concurrency: closedConcurrency ?? Math.max(1, Math.round(load.concurrency * weight / totalWeight))
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
function limitedFetch(fetchImpl, maxInFlight) {
|
|
113
|
+
if (maxInFlight == null) return fetchImpl;
|
|
114
|
+
const limiter = createLimiter(maxInFlight);
|
|
115
|
+
const limited = async (input, init) => {
|
|
116
|
+
const release = await limiter.acquire();
|
|
117
|
+
try {
|
|
118
|
+
return await fetchImpl(input, init);
|
|
119
|
+
} finally {
|
|
120
|
+
release();
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
return limited;
|
|
124
|
+
}
|
|
125
|
+
function createLimiter(maxInFlight) {
|
|
126
|
+
if (!Number.isInteger(maxInFlight) || maxInFlight < 1) throw new RangeError(`maxInFlight must be a positive integer; got ${maxInFlight}.`);
|
|
127
|
+
let active = 0;
|
|
128
|
+
const waiters = [];
|
|
129
|
+
return { async acquire() {
|
|
130
|
+
if (active < maxInFlight) active++;
|
|
131
|
+
else await new Promise((resolve) => waiters.push(resolve));
|
|
132
|
+
let released = false;
|
|
133
|
+
return () => {
|
|
134
|
+
if (released) return;
|
|
135
|
+
released = true;
|
|
136
|
+
const next = waiters.shift();
|
|
137
|
+
if (next == null) active--;
|
|
138
|
+
else next();
|
|
139
|
+
};
|
|
140
|
+
} };
|
|
141
|
+
}
|
|
142
|
+
function scaledClosedConcurrencies(concurrency, weights) {
|
|
143
|
+
const totalWeight = weights.reduce((sum, weight) => sum + weight, 0);
|
|
144
|
+
const ideal = weights.map((weight) => concurrency * weight / totalWeight);
|
|
145
|
+
const allocations = weights.map(() => 1);
|
|
146
|
+
for (let remaining = concurrency - weights.length; remaining > 0; remaining--) {
|
|
147
|
+
let best = 0;
|
|
148
|
+
for (let i = 1; i < allocations.length; i++) if (ideal[i] - allocations[i] > ideal[best] - allocations[best]) best = i;
|
|
149
|
+
allocations[best]++;
|
|
150
|
+
}
|
|
151
|
+
return allocations;
|
|
152
|
+
}
|
|
153
|
+
function rawLoad(load) {
|
|
154
|
+
if (load.kind === "open") return {
|
|
155
|
+
rate: load.ratePerSec,
|
|
156
|
+
arrival: load.arrival,
|
|
157
|
+
maxInFlight: load.maxInFlight
|
|
158
|
+
};
|
|
159
|
+
return {
|
|
160
|
+
concurrency: load.concurrency,
|
|
161
|
+
maxInFlight: load.maxInFlight
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
function runnerForChild(type) {
|
|
165
|
+
switch (type) {
|
|
166
|
+
case "inbox": return inboxRunner;
|
|
167
|
+
case "webfinger": return webfingerRunner;
|
|
168
|
+
case "actor": return actorRunner;
|
|
169
|
+
case "object": return objectRunner;
|
|
170
|
+
case "fanout": return fanoutRunner;
|
|
171
|
+
case "failure": return failureRunner;
|
|
172
|
+
default: throw new Error(`The "${type}" scenario type cannot be used inside a mixed scenario.`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function mergeMeasurements(measurements) {
|
|
176
|
+
const total = measurements.reduce((sum, m) => sum + m.requests.total, 0);
|
|
177
|
+
const ok = measurements.reduce((sum, m) => sum + m.requests.ok, 0);
|
|
178
|
+
const histogram = mergeHistograms(measurements);
|
|
179
|
+
const deliveryThroughputs = measurements.map((m) => m.deliveryThroughputPerSec).filter((value) => value != null);
|
|
180
|
+
return {
|
|
181
|
+
requests: {
|
|
182
|
+
total,
|
|
183
|
+
ok,
|
|
184
|
+
failed: total - ok,
|
|
185
|
+
successRate: total === 0 ? 1 : ok / total
|
|
186
|
+
},
|
|
187
|
+
throughputPerSec: measurements.reduce((sum, m) => sum + m.throughputPerSec, 0),
|
|
188
|
+
...deliveryThroughputs.length < 1 ? {} : { deliveryThroughputPerSec: deliveryThroughputs.reduce((sum, value) => sum + value, 0) },
|
|
189
|
+
client: { latencyMs: histogram == null ? mergeLatencyFallback(measurements) : latencyFromHistogram(histogram) },
|
|
190
|
+
server: null,
|
|
191
|
+
errors: mergeErrors(measurements),
|
|
192
|
+
...histogram == null ? {} : { histogram: histogram.toJSON() }
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
function mergeHistograms(measurements) {
|
|
196
|
+
let merged = null;
|
|
197
|
+
for (const measurement of measurements) {
|
|
198
|
+
if (measurement.histogram == null) return null;
|
|
199
|
+
const histogram = LogLinearHistogram.fromJSON(measurement.histogram);
|
|
200
|
+
if (merged == null) merged = histogram;
|
|
201
|
+
else merged.merge(histogram);
|
|
202
|
+
}
|
|
203
|
+
return merged;
|
|
204
|
+
}
|
|
205
|
+
function latencyFromHistogram(histogram) {
|
|
206
|
+
return {
|
|
207
|
+
p50: histogram.percentile(50),
|
|
208
|
+
p95: histogram.percentile(95),
|
|
209
|
+
p99: histogram.percentile(99),
|
|
210
|
+
mean: histogram.mean,
|
|
211
|
+
max: histogram.max
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
function mergeLatencyFallback(measurements) {
|
|
215
|
+
const total = measurements.reduce((sum, m) => sum + m.requests.total, 0);
|
|
216
|
+
if (measurements.length < 1) return {
|
|
217
|
+
p50: 0,
|
|
218
|
+
p95: 0,
|
|
219
|
+
p99: 0,
|
|
220
|
+
mean: 0,
|
|
221
|
+
max: 0
|
|
222
|
+
};
|
|
223
|
+
return {
|
|
224
|
+
p50: Math.max(...measurements.map((m) => m.client.latencyMs.p50)),
|
|
225
|
+
p95: Math.max(...measurements.map((m) => m.client.latencyMs.p95)),
|
|
226
|
+
p99: Math.max(...measurements.map((m) => m.client.latencyMs.p99)),
|
|
227
|
+
mean: total === 0 ? 0 : measurements.reduce((sum, m) => sum + m.client.latencyMs.mean * m.requests.total, 0) / total,
|
|
228
|
+
max: Math.max(...measurements.map((m) => m.client.latencyMs.max))
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
function mergeErrors(measurements) {
|
|
232
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
233
|
+
for (const measurement of measurements) for (const error of measurement.errors) {
|
|
234
|
+
const key = `${error.kind}|${error.status ?? ""}|${error.reason}`;
|
|
235
|
+
const existing = buckets.get(key);
|
|
236
|
+
buckets.set(key, {
|
|
237
|
+
...error,
|
|
238
|
+
count: (existing?.count ?? 0) + error.count
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
return [...buckets.values()].sort((a, b) => b.count - a.count);
|
|
242
|
+
}
|
|
243
|
+
//#endregion
|
|
244
|
+
export { mixedRunner };
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import "@js-temporal/polyfill";
|
|
2
|
+
import { convertUrlIfHandle } from "../../webfinger/lib.js";
|
|
3
|
+
import { asList } from "../scenario/coerce.js";
|
|
4
|
+
//#region src/bench/scenarios/object-discovery.ts
|
|
5
|
+
/**
|
|
6
|
+
* Discovery helpers for actor and object benchmark scenarios.
|
|
7
|
+
* @since 2.3.0
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
const ACTIVITY_JSON_ACCEPT = "application/activity+json, application/ld+json";
|
|
11
|
+
const WEBFINGER_ACCEPT = "application/jrd+json, application/json";
|
|
12
|
+
const MAX_COLLECTION_CRAWL_PAGES = 100;
|
|
13
|
+
const MAX_ACTIVITY_UNWRAP_DEPTH = 10;
|
|
14
|
+
const ACTIVITY_WRAPPER_TYPES = new Set([
|
|
15
|
+
"Accept",
|
|
16
|
+
"Add",
|
|
17
|
+
"Announce",
|
|
18
|
+
"Create",
|
|
19
|
+
"Delete",
|
|
20
|
+
"Dislike",
|
|
21
|
+
"Flag",
|
|
22
|
+
"Ignore",
|
|
23
|
+
"Invite",
|
|
24
|
+
"Join",
|
|
25
|
+
"Leave",
|
|
26
|
+
"Like",
|
|
27
|
+
"Listen",
|
|
28
|
+
"Move",
|
|
29
|
+
"Offer",
|
|
30
|
+
"Question",
|
|
31
|
+
"Read",
|
|
32
|
+
"Reject",
|
|
33
|
+
"Remove",
|
|
34
|
+
"TentativeAccept",
|
|
35
|
+
"TentativeReject",
|
|
36
|
+
"Travel",
|
|
37
|
+
"Undo",
|
|
38
|
+
"Update",
|
|
39
|
+
"View"
|
|
40
|
+
]);
|
|
41
|
+
/** Resolves scenario recipients into actor document URLs. */
|
|
42
|
+
async function actorUrlsFromRecipients(recipients, options) {
|
|
43
|
+
const urls = [];
|
|
44
|
+
for (const recipient of recipients) urls.push(await actorUrlFromRecipient(recipient, options));
|
|
45
|
+
return urls;
|
|
46
|
+
}
|
|
47
|
+
/** Resolves object scenario sources into object URLs. */
|
|
48
|
+
async function objectUrlsFromSource(options) {
|
|
49
|
+
const { source } = options;
|
|
50
|
+
if (source == null) return [];
|
|
51
|
+
if (typeof source === "string" || Array.isArray(source)) return asList(source).map((url) => new URL(url));
|
|
52
|
+
const limit = source.limit ?? 100;
|
|
53
|
+
const types = new Set(asList(source.type));
|
|
54
|
+
const urls = [];
|
|
55
|
+
for (const seed of asList(source.seed)) {
|
|
56
|
+
const actorUrl = await actorUrlFromRecipient(seed, options);
|
|
57
|
+
await options.assertReadDestinationAllowed?.(actorUrl);
|
|
58
|
+
const actor = await fetchJson(actorUrl, options.fetch);
|
|
59
|
+
for (const collectionName of asList(source.collection ?? "outbox")) {
|
|
60
|
+
const collectionUrl = propertyUrl(actor, collectionName, actorUrl);
|
|
61
|
+
if (collectionUrl == null) continue;
|
|
62
|
+
for await (const objectUrl of crawlCollection(collectionUrl, {
|
|
63
|
+
fetch: options.fetch,
|
|
64
|
+
assertReadDestinationAllowed: options.assertReadDestinationAllowed,
|
|
65
|
+
types,
|
|
66
|
+
limit: limit - urls.length
|
|
67
|
+
})) {
|
|
68
|
+
urls.push(objectUrl);
|
|
69
|
+
if (urls.length >= limit) return urls;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return urls;
|
|
74
|
+
}
|
|
75
|
+
async function actorUrlFromRecipient(recipient, options) {
|
|
76
|
+
const identifier = convertUrlIfHandle(recipient);
|
|
77
|
+
if (identifier.protocol !== "acct:") return identifier;
|
|
78
|
+
const url = new URL("/.well-known/webfinger", options.target);
|
|
79
|
+
url.searchParams.set("resource", identifier.href);
|
|
80
|
+
const jrd = await fetchJson(url, options.fetch, WEBFINGER_ACCEPT);
|
|
81
|
+
const self = (Array.isArray(jrd.links) ? jrd.links : []).find((link) => isRecord(link) && link.rel === "self" && typeof link.href === "string");
|
|
82
|
+
if (!isRecord(self) || typeof self.href !== "string") throw new Error(`WebFinger response for ${recipient} has no self link.`);
|
|
83
|
+
return new URL(self.href);
|
|
84
|
+
}
|
|
85
|
+
async function* crawlCollection(start, options) {
|
|
86
|
+
let next = start;
|
|
87
|
+
let remaining = options.limit;
|
|
88
|
+
let pages = 0;
|
|
89
|
+
const visited = /* @__PURE__ */ new Set();
|
|
90
|
+
while (next != null && remaining > 0 && pages < MAX_COLLECTION_CRAWL_PAGES) {
|
|
91
|
+
if (visited.has(next.href)) return;
|
|
92
|
+
visited.add(next.href);
|
|
93
|
+
await options.assertReadDestinationAllowed?.(next);
|
|
94
|
+
const page = await fetchJson(next, options.fetch);
|
|
95
|
+
pages++;
|
|
96
|
+
const items = arrayProperty(page, "orderedItems") ?? arrayProperty(page, "items") ?? [];
|
|
97
|
+
for (const item of items) {
|
|
98
|
+
const url = await objectUrl(item, {
|
|
99
|
+
base: next,
|
|
100
|
+
fetch: options.fetch,
|
|
101
|
+
assertReadDestinationAllowed: options.assertReadDestinationAllowed,
|
|
102
|
+
types: options.types
|
|
103
|
+
});
|
|
104
|
+
if (url == null) continue;
|
|
105
|
+
yield url;
|
|
106
|
+
remaining--;
|
|
107
|
+
if (remaining <= 0) return;
|
|
108
|
+
}
|
|
109
|
+
const first = propertyUrl(page, "first", next);
|
|
110
|
+
next = propertyUrl(page, "next", next) ?? (next.href === start.href ? first : null);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
async function fetchJson(url, fetchImpl = fetch, accept = ACTIVITY_JSON_ACCEPT) {
|
|
114
|
+
const response = await fetchImpl(new Request(url, {
|
|
115
|
+
headers: { accept },
|
|
116
|
+
redirect: "manual"
|
|
117
|
+
}));
|
|
118
|
+
if (!response.ok) {
|
|
119
|
+
await response.arrayBuffer().catch(() => {});
|
|
120
|
+
throw new Error(`Failed to fetch ${url.href}: HTTP ${response.status}.`);
|
|
121
|
+
}
|
|
122
|
+
let json;
|
|
123
|
+
try {
|
|
124
|
+
json = await response.json();
|
|
125
|
+
} catch (error) {
|
|
126
|
+
throw new Error(`Failed to parse JSON from ${url.href}: ${error}`);
|
|
127
|
+
}
|
|
128
|
+
if (!isRecord(json)) throw new Error(`Expected ${url.href} to return a JSON object.`);
|
|
129
|
+
return json;
|
|
130
|
+
}
|
|
131
|
+
async function objectUrl(item, options) {
|
|
132
|
+
for (const candidate of objectCandidates(item)) {
|
|
133
|
+
if (typeof candidate === "string") {
|
|
134
|
+
const url = safeUrl(candidate, options.base);
|
|
135
|
+
if (url == null) continue;
|
|
136
|
+
if (options.types.size < 1) return url;
|
|
137
|
+
const typedUrl = await typedReferencedObjectUrl(url, options);
|
|
138
|
+
if (typedUrl != null) return typedUrl;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (!isRecord(candidate)) continue;
|
|
142
|
+
if (options.types.size > 0 && !matchesType(candidate.type, options.types)) continue;
|
|
143
|
+
const url = propertyUrl(candidate, "id", options.base);
|
|
144
|
+
if (url != null) return url;
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
async function typedReferencedObjectUrl(url, options, seen = /* @__PURE__ */ new Set(), depth = 0) {
|
|
149
|
+
if (depth > MAX_ACTIVITY_UNWRAP_DEPTH) return null;
|
|
150
|
+
if (seen.has(url.href)) return null;
|
|
151
|
+
seen.add(url.href);
|
|
152
|
+
await options.assertReadDestinationAllowed?.(url);
|
|
153
|
+
let object;
|
|
154
|
+
try {
|
|
155
|
+
object = await fetchJson(url, options.fetch);
|
|
156
|
+
} catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
for (const candidate of objectCandidates(object)) {
|
|
160
|
+
if (typeof candidate === "string") {
|
|
161
|
+
const candidateUrl = safeUrl(candidate, url);
|
|
162
|
+
if (candidateUrl == null) continue;
|
|
163
|
+
const typedUrl = await typedReferencedObjectUrl(candidateUrl, options, seen, depth + 1);
|
|
164
|
+
if (typedUrl != null) return typedUrl;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (!isRecord(candidate)) continue;
|
|
168
|
+
if (!matchesType(candidate.type, options.types)) continue;
|
|
169
|
+
return propertyUrl(candidate, "id", url) ?? url;
|
|
170
|
+
}
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
function objectCandidates(item, depth = 0, seen = /* @__PURE__ */ new WeakSet()) {
|
|
174
|
+
if (depth > MAX_ACTIVITY_UNWRAP_DEPTH) return [];
|
|
175
|
+
if (!isRecord(item) || !matchesType(item.type, ACTIVITY_WRAPPER_TYPES)) return [item];
|
|
176
|
+
if (seen.has(item)) return [];
|
|
177
|
+
seen.add(item);
|
|
178
|
+
const object = item.object;
|
|
179
|
+
if (object == null) return [];
|
|
180
|
+
if (Array.isArray(object)) return object.flatMap((entry) => entry == null ? [] : objectCandidates(entry, depth + 1, seen));
|
|
181
|
+
return objectCandidates(object, depth + 1, seen);
|
|
182
|
+
}
|
|
183
|
+
function matchesType(type, expected) {
|
|
184
|
+
if (typeof type === "string") return expected.has(type);
|
|
185
|
+
return Array.isArray(type) && type.some((item) => typeof item === "string" && expected.has(item));
|
|
186
|
+
}
|
|
187
|
+
function propertyUrl(object, key, base) {
|
|
188
|
+
const value = object[key];
|
|
189
|
+
if (typeof value === "string") return safeUrl(value, base);
|
|
190
|
+
if (isRecord(value)) {
|
|
191
|
+
if (typeof value.href === "string") return safeUrl(value.href, base);
|
|
192
|
+
if (typeof value.id === "string") return safeUrl(value.id, base);
|
|
193
|
+
}
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
function arrayProperty(object, key) {
|
|
197
|
+
const value = object[key];
|
|
198
|
+
return Array.isArray(value) ? value : null;
|
|
199
|
+
}
|
|
200
|
+
function isRecord(value) {
|
|
201
|
+
return value != null && typeof value === "object" && !Array.isArray(value);
|
|
202
|
+
}
|
|
203
|
+
function safeUrl(value, base) {
|
|
204
|
+
try {
|
|
205
|
+
return new URL(value, base);
|
|
206
|
+
} catch {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
//#endregion
|
|
211
|
+
export { actorUrlsFromRecipients, objectUrlsFromSource };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import "@js-temporal/polyfill";
|
|
2
|
+
import { convertUrlIfHandle } from "../../webfinger/lib.js";
|
|
3
|
+
import { asList } from "../scenario/coerce.js";
|
|
4
|
+
import { objectUrlsFromSource } from "./object-discovery.js";
|
|
5
|
+
import { assertBareHttpUrl, isBareHttpUrl } from "./runner.js";
|
|
6
|
+
import { runReadLoad } from "./read.js";
|
|
7
|
+
//#region src/bench/scenarios/object.ts
|
|
8
|
+
/**
|
|
9
|
+
* The `object` scenario runner.
|
|
10
|
+
* @since 2.3.0
|
|
11
|
+
* @module
|
|
12
|
+
*/
|
|
13
|
+
/** The `object` scenario runner. */
|
|
14
|
+
const objectRunner = {
|
|
15
|
+
validate(scenario) {
|
|
16
|
+
const { source } = scenario;
|
|
17
|
+
if (source == null) return;
|
|
18
|
+
if (typeof source === "string" || Array.isArray(source)) {
|
|
19
|
+
for (const url of asList(source)) {
|
|
20
|
+
let parsed;
|
|
21
|
+
try {
|
|
22
|
+
parsed = new URL(url);
|
|
23
|
+
} catch {
|
|
24
|
+
throw new Error(`Scenario "${scenario.name}": invalid object source URL ${JSON.stringify(url)}.`);
|
|
25
|
+
}
|
|
26
|
+
assertBareHttpUrl(scenario.name, "object source URL", parsed);
|
|
27
|
+
}
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
for (const seed of asList(source.seed)) {
|
|
31
|
+
let url;
|
|
32
|
+
try {
|
|
33
|
+
url = convertUrlIfHandle(seed);
|
|
34
|
+
} catch {
|
|
35
|
+
throw new Error(`Scenario "${scenario.name}": invalid object source seed URL ${JSON.stringify(seed)}.`);
|
|
36
|
+
}
|
|
37
|
+
if (url.protocol !== "acct:" && !isBareHttpUrl(url)) throw new Error(`Scenario "${scenario.name}": object source seed must be an acct: handle or a bare http(s) URL with a host and no credentials; got ${JSON.stringify(url.href)}.`);
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
async run(context) {
|
|
41
|
+
this.validate?.(context.scenario);
|
|
42
|
+
return await runReadLoad(context, {
|
|
43
|
+
urls: await objectUrlsFromSource({
|
|
44
|
+
source: context.scenario.source,
|
|
45
|
+
target: context.target,
|
|
46
|
+
fetch: context.fetch,
|
|
47
|
+
assertReadDestinationAllowed: context.assertReadDestinationAllowed
|
|
48
|
+
}),
|
|
49
|
+
authenticated: context.scenario.authenticated
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
//#endregion
|
|
54
|
+
export { objectRunner };
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import "@js-temporal/polyfill";
|
|
2
|
+
import { runLoad } from "../load/generator.js";
|
|
3
|
+
import { aggregateSamples } from "../metrics/aggregate.js";
|
|
4
|
+
import { diffSnapshots, fetchServerSnapshot, snapshotToMetrics } from "../metrics/stats-client.js";
|
|
5
|
+
import { createSigningPipeline } from "../signing/pipeline.js";
|
|
6
|
+
import { assertBareHttpUrl, estimateTotal, loadPlanOf, measuredWindowMs, sendRequest, withMeasuredWindowStart } from "./runner.js";
|
|
7
|
+
import { signRequest } from "@fedify/fedify";
|
|
8
|
+
//#region src/bench/scenarios/read.ts
|
|
9
|
+
/**
|
|
10
|
+
* Shared helpers for read-only benchmark scenarios.
|
|
11
|
+
* @since 2.3.0
|
|
12
|
+
* @module
|
|
13
|
+
*/
|
|
14
|
+
const READ_GATE_CONCURRENCY = 16;
|
|
15
|
+
/**
|
|
16
|
+
* Runs a read-only GET workload and aggregates client/server measurements.
|
|
17
|
+
* @param context The scenario run context.
|
|
18
|
+
* @param options Read workload options.
|
|
19
|
+
* @returns The scenario measurement.
|
|
20
|
+
*/
|
|
21
|
+
async function runReadLoad(context, options) {
|
|
22
|
+
if (options.urls.length < 1) throw new Error(`Scenario "${context.scenario.name}" did not resolve any URLs to fetch.`);
|
|
23
|
+
const fetchImpl = context.fetch ?? fetch;
|
|
24
|
+
const actors = context.fleet?.actors ?? [];
|
|
25
|
+
if (options.authenticated && actors.length < 1) throw new Error(`Scenario "${context.scenario.name}" requires the synthetic actor server for authenticated fetches.`);
|
|
26
|
+
for (const url of options.urls) assertBareHttpUrl(context.scenario.name, "read URL", url);
|
|
27
|
+
await mapWithConcurrency(options.urls, READ_GATE_CONCURRENCY, async (url) => {
|
|
28
|
+
if (options.authenticated) await context.assertDestinationAllowed?.(url);
|
|
29
|
+
else await context.assertReadDestinationAllowed?.(url);
|
|
30
|
+
});
|
|
31
|
+
function unsignedRequest(index) {
|
|
32
|
+
const url = options.urls[index % options.urls.length];
|
|
33
|
+
return new Request(url, {
|
|
34
|
+
headers: { accept: "application/activity+json, application/ld+json" },
|
|
35
|
+
redirect: "manual"
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
let pipeline = null;
|
|
39
|
+
if (options.authenticated) {
|
|
40
|
+
let signIndex = 0;
|
|
41
|
+
pipeline = createSigningPipeline(context.scenario.signing, async () => {
|
|
42
|
+
const i = signIndex++;
|
|
43
|
+
return await signGetRequest(unsignedRequest(i), actors[i % actors.length]);
|
|
44
|
+
}, { total: estimateTotal(context.scenario) });
|
|
45
|
+
}
|
|
46
|
+
let index = 0;
|
|
47
|
+
const rawSend = async () => {
|
|
48
|
+
let request;
|
|
49
|
+
if (pipeline != null) try {
|
|
50
|
+
request = await pipeline.next();
|
|
51
|
+
} catch (error) {
|
|
52
|
+
return {
|
|
53
|
+
ok: false,
|
|
54
|
+
errorKind: "client",
|
|
55
|
+
reason: String(error)
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
else request = unsignedRequest(index++);
|
|
59
|
+
return await sendRequest(request, fetchImpl);
|
|
60
|
+
};
|
|
61
|
+
let baseline = null;
|
|
62
|
+
let baselineTaken = false;
|
|
63
|
+
const send = withMeasuredWindowStart(context.scenario.warmupMs, async () => {
|
|
64
|
+
baseline = await fetchServerSnapshot(context.target, fetchImpl);
|
|
65
|
+
baselineTaken = true;
|
|
66
|
+
}, rawSend);
|
|
67
|
+
try {
|
|
68
|
+
await pipeline?.prime();
|
|
69
|
+
const measurement = aggregateSamples((await runLoad(loadPlanOf(context.scenario, context.rng), send, context.clock, context.signal)).samples, {
|
|
70
|
+
measuredWindowMs: measuredWindowMs(context.scenario),
|
|
71
|
+
includeHistogram: true
|
|
72
|
+
});
|
|
73
|
+
const end = await fetchServerSnapshot(context.target, fetchImpl);
|
|
74
|
+
const server = baselineTaken && baseline != null && end != null ? snapshotToMetrics(diffSnapshots(baseline, end)) : null;
|
|
75
|
+
return {
|
|
76
|
+
...measurement,
|
|
77
|
+
server
|
|
78
|
+
};
|
|
79
|
+
} finally {
|
|
80
|
+
await pipeline?.close();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async function mapWithConcurrency(items, concurrency, callback) {
|
|
84
|
+
let next = 0;
|
|
85
|
+
let firstError;
|
|
86
|
+
let hasError = false;
|
|
87
|
+
const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
|
88
|
+
while (next < items.length && !hasError) {
|
|
89
|
+
const item = items[next++];
|
|
90
|
+
try {
|
|
91
|
+
await callback(item);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (!hasError) {
|
|
94
|
+
hasError = true;
|
|
95
|
+
firstError = error;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
await Promise.all(workers);
|
|
101
|
+
if (hasError) throw firstError;
|
|
102
|
+
}
|
|
103
|
+
async function signGetRequest(request, actor) {
|
|
104
|
+
if (actor.keys?.rsa == null || actor.rsaKeyId == null) throw new TypeError("Actor is missing the RSA key required for authenticated fetch signing.");
|
|
105
|
+
return await signRequest(request, actor.keys.rsa.privateKey, actor.rsaKeyId, { spec: actor.httpStandard });
|
|
106
|
+
}
|
|
107
|
+
//#endregion
|
|
108
|
+
export { runReadLoad };
|
|
@@ -1,9 +1,22 @@
|
|
|
1
1
|
import "@js-temporal/polyfill";
|
|
2
|
+
import { actorRunner } from "./actor.js";
|
|
3
|
+
import { fanoutRunner } from "./fanout.js";
|
|
4
|
+
import { failureRunner } from "./failure.js";
|
|
2
5
|
import { inboxRunner } from "./inbox.js";
|
|
6
|
+
import { objectRunner } from "./object.js";
|
|
3
7
|
import { webfingerRunner } from "./webfinger.js";
|
|
8
|
+
import { mixedRunner } from "./mixed.js";
|
|
4
9
|
//#region src/bench/scenarios/registry.ts
|
|
5
10
|
/** The scenario types that have runners in this version. */
|
|
6
|
-
const IMPLEMENTED_SCENARIO_TYPES = [
|
|
11
|
+
const IMPLEMENTED_SCENARIO_TYPES = [
|
|
12
|
+
"inbox",
|
|
13
|
+
"webfinger",
|
|
14
|
+
"actor",
|
|
15
|
+
"object",
|
|
16
|
+
"fanout",
|
|
17
|
+
"failure",
|
|
18
|
+
"mixed"
|
|
19
|
+
];
|
|
7
20
|
/**
|
|
8
21
|
* Returns the runner for a scenario type.
|
|
9
22
|
* @param type The scenario type.
|
|
@@ -14,6 +27,11 @@ function runnerFor(type) {
|
|
|
14
27
|
switch (type) {
|
|
15
28
|
case "inbox": return inboxRunner;
|
|
16
29
|
case "webfinger": return webfingerRunner;
|
|
30
|
+
case "actor": return actorRunner;
|
|
31
|
+
case "object": return objectRunner;
|
|
32
|
+
case "fanout": return fanoutRunner;
|
|
33
|
+
case "failure": return failureRunner;
|
|
34
|
+
case "mixed": return mixedRunner;
|
|
17
35
|
default: throw new Error(`The "${type}" scenario type is not implemented in this version of fedify bench; supported types: ${IMPLEMENTED_SCENARIO_TYPES.join(", ")}.`);
|
|
18
36
|
}
|
|
19
37
|
}
|
|
@@ -46,6 +46,26 @@ function estimateTotal(scenario) {
|
|
|
46
46
|
if (scenario.load.kind !== "open") return void 0;
|
|
47
47
|
return Math.ceil(scenario.load.ratePerSec * (scenario.durationMs / 1e3));
|
|
48
48
|
}
|
|
49
|
+
/** Returns whether a URL is fetchable by benchmark runners without surprises. */
|
|
50
|
+
function isBareHttpUrl(url) {
|
|
51
|
+
return (url.protocol === "http:" || url.protocol === "https:") && url.hostname !== "" && url.username === "" && url.password === "";
|
|
52
|
+
}
|
|
53
|
+
/** Rejects URLs that are not bare http(s) URLs with a host and no credentials. */
|
|
54
|
+
function assertBareHttpUrl(scenarioName, label, url) {
|
|
55
|
+
if (isBareHttpUrl(url)) return;
|
|
56
|
+
throw new Error(`Scenario "${scenarioName}": ${label} must be a bare http(s) URL with a host and no credentials; got ${JSON.stringify(url.href)}.`);
|
|
57
|
+
}
|
|
58
|
+
/** Validates an inbox selector or explicit inbox URL. */
|
|
59
|
+
function validateInboxSelector(scenarioName, inbox) {
|
|
60
|
+
if (inbox == null || inbox === "shared" || inbox === "personal") return;
|
|
61
|
+
let url;
|
|
62
|
+
try {
|
|
63
|
+
url = new URL(inbox);
|
|
64
|
+
} catch {
|
|
65
|
+
throw new Error(`Scenario "${scenarioName}": inbox must be "shared", "personal", or an http(s) URL; got ${JSON.stringify(inbox)}.`);
|
|
66
|
+
}
|
|
67
|
+
assertBareHttpUrl(scenarioName, "inbox URL", url);
|
|
68
|
+
}
|
|
49
69
|
/**
|
|
50
70
|
* Wraps a send function so that `onMeasuredWindowStart` runs exactly once, at
|
|
51
71
|
* the warm-up boundary, and *every* measured request waits for it to settle
|
|
@@ -73,4 +93,4 @@ function withMeasuredWindowStart(warmupMs, onMeasuredWindowStart, send) {
|
|
|
73
93
|
};
|
|
74
94
|
}
|
|
75
95
|
//#endregion
|
|
76
|
-
export { estimateTotal, loadPlanOf, measuredWindowMs, sendRequest, withMeasuredWindowStart };
|
|
96
|
+
export { assertBareHttpUrl, estimateTotal, isBareHttpUrl, loadPlanOf, measuredWindowMs, sendRequest, validateInboxSelector, withMeasuredWindowStart };
|