@fedify/cli 2.3.0-dev.994 → 2.3.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.
- package/dist/bench/action.js +469 -0
- package/dist/bench/actor/documents.js +39 -0
- package/dist/bench/actor/fleet.js +39 -0
- package/dist/bench/actor/keys.js +35 -0
- package/dist/bench/command.js +72 -0
- package/dist/bench/compare/schema.js +16 -0
- package/dist/bench/compare.js +667 -0
- package/dist/bench/discovery/discover.js +67 -0
- package/dist/bench/discovery/probe.js +50 -0
- package/dist/bench/load/arrival.js +27 -0
- package/dist/bench/load/clock.js +33 -0
- package/dist/bench/load/generator.js +145 -0
- package/dist/bench/metrics/aggregate.js +64 -0
- package/dist/bench/metrics/histogram.js +141 -0
- package/dist/bench/metrics/stats-client.js +216 -0
- package/dist/bench/mod.js +11 -0
- package/dist/bench/render/format.js +46 -0
- package/dist/bench/render/index.js +20 -0
- package/dist/bench/render/json.js +12 -0
- package/dist/bench/render/markdown.js +63 -0
- package/dist/bench/render/text.js +75 -0
- package/dist/bench/result/build.js +252 -0
- package/dist/bench/result/expect/assert.js +74 -0
- package/dist/bench/result/expect/evaluate.js +128 -0
- package/dist/bench/result/expect/metrics.js +34 -0
- package/dist/bench/result/schema.js +365 -0
- package/dist/bench/safety/gate.js +62 -0
- package/dist/bench/safety/tiers.js +97 -0
- package/dist/bench/scenario/coerce.js +24 -0
- package/dist/bench/scenario/errors.js +36 -0
- package/dist/bench/scenario/load.js +69 -0
- package/dist/bench/scenario/normalize.js +125 -0
- package/dist/bench/scenario/schema.js +399 -0
- package/dist/bench/scenario/units.js +56 -0
- package/dist/bench/scenario/validate.js +29 -0
- 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 +147 -0
- 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 +39 -0
- package/dist/bench/scenarios/runner.js +96 -0
- package/dist/bench/scenarios/webfinger.js +44 -0
- package/dist/bench/server/synthetic.js +118 -0
- package/dist/bench/signing/activity-id.js +18 -0
- package/dist/bench/signing/pipeline.js +134 -0
- package/dist/bench/signing/signer.js +39 -0
- package/dist/bench/template/generate.js +90 -0
- package/dist/bench/template/helpers.js +19 -0
- package/dist/bench/template/template.js +132 -0
- package/dist/cache.js +2 -2
- package/dist/commands.js +110 -0
- package/dist/config.js +15 -3
- package/dist/deno.js +1 -1
- package/dist/docloader.js +1 -1
- package/dist/generate-vocab/action.js +3 -3
- package/dist/generate-vocab/command.js +6 -4
- package/dist/imagerenderer.js +3 -3
- package/dist/inbox/command.js +6 -4
- package/dist/inbox/view.js +1 -1
- package/dist/inbox.js +4 -4
- package/dist/log.js +2 -2
- package/dist/lookup/command.js +121 -0
- package/dist/lookup.js +27 -138
- package/dist/mod.js +2 -20
- package/dist/nodeinfo.js +53 -12
- package/dist/options.js +1 -1
- package/dist/relay/command.js +6 -4
- package/dist/relay.js +3 -3
- package/dist/runner.js +70 -45
- package/dist/tunnel.js +8 -6
- package/dist/utils.js +9 -4
- package/dist/webfinger/action.js +1 -1
- package/dist/webfinger/command.js +6 -4
- package/dist/webfinger/error.js +2 -0
- package/dist/webfinger/lib.js +1 -1
- package/package.json +28 -23
- 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,147 @@
|
|
|
1
|
+
import "@js-temporal/polyfill";
|
|
2
|
+
import { discoverInbox, selectInbox } from "../discovery/discover.js";
|
|
3
|
+
import { asList } from "../scenario/coerce.js";
|
|
4
|
+
import { runLoad } from "../load/generator.js";
|
|
5
|
+
import { aggregateSamples } from "../metrics/aggregate.js";
|
|
6
|
+
import { diffSnapshots, fetchServerSnapshot, snapshotToMetrics } from "../metrics/stats-client.js";
|
|
7
|
+
import { createSigningPipeline } from "../signing/pipeline.js";
|
|
8
|
+
import { estimateTotal, loadPlanOf, measuredWindowMs, sendRequest, validateInboxSelector, withMeasuredWindowStart } from "./runner.js";
|
|
9
|
+
import { createActivityIdMinter } from "../signing/activity-id.js";
|
|
10
|
+
import { signInboxDelivery } from "../signing/signer.js";
|
|
11
|
+
import { isGenerateDirective, resolveGenerate } from "../template/generate.js";
|
|
12
|
+
import { Create, Note } from "@fedify/vocab";
|
|
13
|
+
//#region src/bench/scenarios/inbox.ts
|
|
14
|
+
/**
|
|
15
|
+
* The `inbox` scenario runner: the end-to-end signed-delivery benchmark.
|
|
16
|
+
*
|
|
17
|
+
* It discovers the recipient's inbox the way a real peer does, then drives
|
|
18
|
+
* signed activity deliveries through the signing pipeline, aggregates the
|
|
19
|
+
* client-measured results, and reads the target's server-side metrics.
|
|
20
|
+
* @since 2.3.0
|
|
21
|
+
* @module
|
|
22
|
+
*/
|
|
23
|
+
/** The `inbox` scenario runner. */
|
|
24
|
+
const inboxRunner = {
|
|
25
|
+
validate(scenario) {
|
|
26
|
+
validateActivity(scenario);
|
|
27
|
+
validateInbox(scenario);
|
|
28
|
+
},
|
|
29
|
+
async run(context) {
|
|
30
|
+
const { scenario, fleet } = context;
|
|
31
|
+
if (fleet == null || fleet.actors.length < 1) throw new Error("The inbox scenario requires the synthetic actor server.");
|
|
32
|
+
if (scenario.recipients.length < 1) throw new Error("The inbox scenario requires a recipient.");
|
|
33
|
+
validateActivity(scenario);
|
|
34
|
+
validateInbox(scenario);
|
|
35
|
+
const fetchImpl = context.fetch ?? fetch;
|
|
36
|
+
const targets = [];
|
|
37
|
+
for (const recipient of scenario.recipients) {
|
|
38
|
+
const discovered = await discoverInbox(recipient, {
|
|
39
|
+
documentLoader: context.documentLoader,
|
|
40
|
+
contextLoader: context.contextLoader,
|
|
41
|
+
allowPrivateAddress: context.allowPrivateAddress
|
|
42
|
+
});
|
|
43
|
+
const inbox = selectInbox(discovered, scenario.inbox);
|
|
44
|
+
await context.assertDestinationAllowed?.(inbox);
|
|
45
|
+
targets.push({
|
|
46
|
+
inbox,
|
|
47
|
+
actorUri: discovered.actorUri
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
const actors = fleet.actors;
|
|
51
|
+
const minter = createActivityIdMinter(fleet.url);
|
|
52
|
+
let index = 0;
|
|
53
|
+
const factory = () => {
|
|
54
|
+
const i = index++;
|
|
55
|
+
const actor = actors[i % actors.length];
|
|
56
|
+
const target = targets[i % targets.length];
|
|
57
|
+
const activity = buildActivity(scenario.activity, actor, minter.next(), fleet.url, target.actorUri);
|
|
58
|
+
return signInboxDelivery({
|
|
59
|
+
actor,
|
|
60
|
+
inbox: target.inbox,
|
|
61
|
+
activity,
|
|
62
|
+
contextLoader: context.contextLoader
|
|
63
|
+
});
|
|
64
|
+
};
|
|
65
|
+
const pipeline = createSigningPipeline(scenario.signing, factory, { total: estimateTotal(scenario) });
|
|
66
|
+
const rawSend = async () => {
|
|
67
|
+
let request;
|
|
68
|
+
try {
|
|
69
|
+
request = await pipeline.next();
|
|
70
|
+
} catch (error) {
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
errorKind: "client",
|
|
74
|
+
reason: String(error)
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return sendRequest(request, fetchImpl);
|
|
78
|
+
};
|
|
79
|
+
let baseline = null;
|
|
80
|
+
let baselineTaken = false;
|
|
81
|
+
const send = withMeasuredWindowStart(scenario.warmupMs, async () => {
|
|
82
|
+
baseline = await fetchServerSnapshot(context.target, fetchImpl);
|
|
83
|
+
baselineTaken = true;
|
|
84
|
+
}, rawSend);
|
|
85
|
+
try {
|
|
86
|
+
await pipeline.prime();
|
|
87
|
+
const measurement = aggregateSamples((await runLoad(loadPlanOf(scenario, context.rng), send, context.clock, context.signal)).samples, {
|
|
88
|
+
measuredWindowMs: measuredWindowMs(scenario),
|
|
89
|
+
includeHistogram: true
|
|
90
|
+
});
|
|
91
|
+
const end = await fetchServerSnapshot(context.target, fetchImpl);
|
|
92
|
+
const server = baselineTaken && baseline != null && end != null ? snapshotToMetrics(diffSnapshots(baseline, end)) : null;
|
|
93
|
+
return {
|
|
94
|
+
...measurement,
|
|
95
|
+
server
|
|
96
|
+
};
|
|
97
|
+
} finally {
|
|
98
|
+
await pipeline.close();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
/**
|
|
103
|
+
* Validates the scenario's `inbox` mode. `"shared"` and `"personal"` select a
|
|
104
|
+
* discovered inbox; any other value is an explicit inbox URL the run will POST
|
|
105
|
+
* to, so it must be a usable bare http(s) URL. Without this preflight check, a
|
|
106
|
+
* typo like `inbox: shraed` would crash `selectInbox` with an uncaught error
|
|
107
|
+
* mid-run, and a non-http URL would slip through to the send path.
|
|
108
|
+
*/
|
|
109
|
+
function validateInbox(scenario) {
|
|
110
|
+
validateInboxSelector(scenario.name, scenario.inbox);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Rejects the activity options the inbox runner cannot yet honor: it always
|
|
114
|
+
* delivers a `Create` carrying an embedded `Note`, so a different activity or
|
|
115
|
+
* object type, or `embedObject: false`, is refused with a clear message.
|
|
116
|
+
*/
|
|
117
|
+
function validateActivity(scenario) {
|
|
118
|
+
const spec = scenario.activity;
|
|
119
|
+
if (spec == null) return;
|
|
120
|
+
const badType = asList(spec.type).find((type) => type !== "Create");
|
|
121
|
+
if (badType != null) throw new Error(`Scenario "${scenario.name}": the inbox runner currently supports only Create activities; got ${JSON.stringify(badType)}.`);
|
|
122
|
+
if (spec.embedObject === false) throw new Error(`Scenario "${scenario.name}": the inbox runner always embeds the activity's object; embedObject: false is not yet supported.`);
|
|
123
|
+
const badObjectType = asList(spec.object?.type).find((type) => type !== "Note");
|
|
124
|
+
if (badObjectType != null) throw new Error(`Scenario "${scenario.name}": the inbox runner currently supports only Note objects; got ${JSON.stringify(badObjectType)}.`);
|
|
125
|
+
}
|
|
126
|
+
function buildActivity(spec, actor, id, base, recipient) {
|
|
127
|
+
const note = new Note({
|
|
128
|
+
id: new URL(`/objects/${crypto.randomUUID()}`, base),
|
|
129
|
+
attribution: actor.id,
|
|
130
|
+
content: resolveContent(spec?.object?.content),
|
|
131
|
+
to: recipient
|
|
132
|
+
});
|
|
133
|
+
return new Create({
|
|
134
|
+
id,
|
|
135
|
+
actor: actor.id,
|
|
136
|
+
object: note,
|
|
137
|
+
to: recipient
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
function resolveContent(content) {
|
|
141
|
+
if (content == null) return "Benchmark activity.";
|
|
142
|
+
if (typeof content === "string") return content;
|
|
143
|
+
if (isGenerateDirective(content)) return resolveGenerate(content);
|
|
144
|
+
return String(content);
|
|
145
|
+
}
|
|
146
|
+
//#endregion
|
|
147
|
+
export { inboxRunner };
|
|
@@ -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 };
|