Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
ec0a6a6
Scaffold the fedify bench subcommand
dahlia Jun 4, 2026
1815cde
Add a log-linear histogram for benchmark percentiles
dahlia Jun 4, 2026
9e9aba2
Add scenario-format primitives for fedify bench
dahlia Jun 4, 2026
bc11a6d
Add the benchmark scenario format and its JSON Schema
dahlia Jun 4, 2026
d17cecb
Normalize benchmark scenarios into an executable form
dahlia Jun 4, 2026
065ea30
Add the benchmark report model and its JSON Schema
dahlia Jun 4, 2026
eb3379f
Evaluate expect assertions against measured metrics
dahlia Jun 4, 2026
7fae2ae
Build benchmark reports and render them three ways
dahlia Jun 4, 2026
d9812d7
Add benchmark target safety gating and recipient discovery
dahlia Jun 4, 2026
a47b074
Add the synthetic actor/key server for benchmarks
dahlia Jun 4, 2026
1bb3bee
Add the benchmark signing pipeline
dahlia Jun 4, 2026
317079a
Add the benchmark load generator and sample aggregation
dahlia Jun 4, 2026
14c8ac1
Run the inbox and webfinger benchmark scenarios end-to-end
dahlia Jun 4, 2026
f14d032
Wire up the fedify bench orchestrator
dahlia Jun 4, 2026
a27f6a0
Apply scenario templates when loading a suite
dahlia Jun 4, 2026
aa1e5ca
Document the fedify bench command
dahlia Jun 4, 2026
490038a
Honor or reject inbox scenario options in fedify bench
dahlia Jun 4, 2026
0e32942
Validate expect assertions before sending bench load
dahlia Jun 5, 2026
96ddf54
Scope bench server metrics to the measured window
dahlia Jun 5, 2026
782aea0
Accept partial load overrides in the bench scenario schema
dahlia Jun 5, 2026
98a22d7
Make signed bench targets reach the synthetic actor server
dahlia Jun 5, 2026
f1ae5b7
Apply the configured User-Agent to all bench traffic
dahlia Jun 5, 2026
80dd18a
Show server queue depth without drain latency in reports
dahlia Jun 5, 2026
9b8881e
Reject non-HTTP and credentialed bench targets
dahlia Jun 5, 2026
b2d4ad9
Gate every inbox load destination, not just the target
dahlia Jun 5, 2026
4d980e2
Do not follow redirects in benchmark traffic
dahlia Jun 5, 2026
581a604
Raise the Bun test timeout for @fedify/cli
dahlia Jun 5, 2026
8a89a5d
Fix the docs build broken by inline templating braces
dahlia Jun 5, 2026
59c6b13
Show templating braces via a v-pre container in the docs
dahlia Jun 5, 2026
cc6a588
Stop refilling the presign buffer during the timed window
dahlia Jun 5, 2026
9d03685
Bound recursion depth and payload size from suite input
dahlia Jun 5, 2026
3e96404
Enforce exactly one HTTP signature standard per actor group
dahlia Jun 5, 2026
374b41e
Make the sumErrors status range a single atomic argument
dahlia Jun 5, 2026
95fd24c
Validate an explicit inbox URL before running
dahlia Jun 5, 2026
46302d3
Re-validate the inbox URL inside the inbox runner's run()
dahlia Jun 5, 2026
d9b5c57
Type the bench preflight runners array explicitly
dahlia Jun 5, 2026
6a1d685
Render templates copy-on-write to avoid cloning unchanged subtrees
dahlia Jun 5, 2026
9da7473
Defer the measured-window callback so a sync throw rejects
dahlia Jun 5, 2026
180abef
Sign exactly the encoded body bytes
dahlia Jun 5, 2026
72f56c7
Handle escaped quotes in template helper arguments
dahlia Jun 5, 2026
428c538
Bind dual-stack when advertising a hostname
dahlia Jun 5, 2026
bf94455
Describe --dry-run accurately as an offline plan preview
dahlia Jun 5, 2026
74a5f3c
Harden server stats parsing against malformed snapshots
dahlia Jun 5, 2026
1f6d30f
Keep the unsafe-target override CLI-only
dahlia Jun 5, 2026
71eb909
Tolerate immutable request headers in withUserAgent
dahlia Jun 5, 2026
b7418a7
Use a valid URL for the webfinger recipient fallback
dahlia Jun 5, 2026
c12f907
Derive module dir without import.meta.dirname for Node 20.0
dahlia Jun 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,19 @@ To be released.
[#782]: https://github.com/fedify-dev/fedify/issues/782
[#787]: https://github.com/fedify-dev/fedify/pull/787

### @fedify/cli

- Added the `fedify bench` command for benchmarking Fedify federation
workloads. It acts as a synthetic remote actor that drives
ActivityPub-specific load (signed inbox deliveries and WebFinger lookups)
against a cooperative `benchmarkMode` target and reports latency,
throughput, success rate, and errors, reading server-side metrics from the
target's stats endpoint. Benchmarks are described by a YAML or JSON
scenario suite validated against a published JSON Schema, with an `expect`
block per scenario that gates a run for CI. [[#744], [#783]]

[#783]: https://github.com/fedify-dev/fedify/issues/783

### @fedify/fixture

- Added `createTestMeterProvider()` and `TestMetricRecorder` helpers for
Expand Down
8 changes: 6 additions & 2 deletions deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

189 changes: 187 additions & 2 deletions docs/manual/benchmarking.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
---
description: >-
Fedify can expose cooperative benchmark endpoints for measuring federation
workloads without requiring an external metrics backend.
Fedify can run as a cooperative benchmark target, and the fedify bench command
drives ActivityPub-specific load against it to measure federation workloads
without requiring an external metrics backend.
---

Benchmarking
Expand Down Expand Up @@ -80,6 +81,190 @@ const federation = createFederation<void>({
~~~~


The `fedify bench` command
--------------------------

*This command is available since Fedify 2.3.0.*

Once a target runs in benchmark mode, the `fedify bench` command drives
ActivityPub-specific load against it and reports latency, throughput, success
rate, and errors. It acts as a synthetic remote actor: it generates keys,
serves its own actor and key documents over loopback, and signs every inbox
delivery with the same `@fedify/fedify` signer a real peer uses, so the measured
crypto cost is real.

> [!NOTE]
> This version runs the `inbox` and `webfinger` scenario types. The scenario
> format can express the others (`actor`, `object`, `fanout`, `collection`,
> `failure`, and `mixed`), but they are not executed yet. Within the runnable
> types, a few options the format accepts are also not implemented yet and are
> rejected up front with a clear message:
>
> - `runs` greater than `1` (repeated runs).
> - An `inbox` `activity` that is not a `Create` carrying an embedded `Note`;
> that is, a non-`Create` `type`, a non-`Note` `object.type`, or
> `embedObject: false`.
> - A `warmup` that is not shorter than the `duration` (which would leave no
> measured window).

### A scenario suite

A benchmark is described by a *suite* file in YAML (JSON works too, since YAML
is a superset). The suite declares the `target`, shared `defaults`, the
`actors` to sign as, and a list of `scenarios`, each with an optional `expect`
block of pass/fail thresholds:

~~~~ yaml
# yaml-language-server: $schema=https://json-schema.fedify.dev/bench/scenario-v1.json
version: 1
target: http://localhost:3000
defaults:
duration: 30s
warmup: 5s # excluded from results; also warms the key cache
load:
rate: 200/s # open-loop; or closed-loop with `concurrency: 50`
actors:
- count: 3
signatureStandards: [draft-cavage-http-signatures-12, ld-signatures]
scenarios:
- name: inbox-shared
type: inbox
recipient: "http://${{ target.host }}/users/alice"
inbox: shared
activity:
type: Create
object:
type: Note
content: { generate: lorem, size: 2KB }
expect:
successRate: ">= 99%"
latency.p95: "< 100ms"
~~~~

Run it against the target and read the terminal report:

~~~~ sh
fedify bench scenario.yaml
~~~~

The `# yaml-language-server:` line gives editors autocomplete and validation
against the [published schema].
Override the file's target with `--target`, choose the output with
`--format`/`--output`, and inspect a run without sending anything with
`--dry-run`.

An `inbox` scenario's `recipient` may be a single value or a list. With a
list, deliveries are rotated across the recipients (and across the synthetic
`actors` signing them), modeling a server that receives from many peers into
many local inboxes.

[published schema]: https://json-schema.fedify.dev/bench/scenario-v1.json

### Actors

You pick signature *standards*, not key algorithms; the key set is derived,
because a Fedify actor is inherently multi-key. An actor uses exactly one HTTP
request signature scheme, plus any document signature schemes:

| Standard | Layer | Algorithm |
| --------------------------------- | ------------ | -------------------------- |
| `draft-cavage-http-signatures-12` | HTTP request | RSA |
| `rfc9421` | HTTP request | RSA |
| `ld-signatures` | document | RSA (`RsaSignature2017`) |
| `fep8b32` | document | Ed25519 (`eddsa-jcs-2022`) |

`draft-cavage-http-signatures-12` and `rfc9421` are mutually exclusive (one HTTP
scheme per actor). Several actor groups with different standard sets model a
heterogeneous fleet, which is what a server actually receives.

### Templating

::: v-pre

Values support GitHub-Actions-style `${{ … }}` templating, kept logic-less
(references and whitelisted helper calls only). For example
`${{ target.host }}` expands to the target's host. Generated payloads use typed
directives such as `content: { generate: lorem, size: 2KB }` rather than string
templates. The tool owns actor URLs and activity ids, so each request gets a
unique activity id automatically (which Fedify's always-on inbox idempotency
requires).

:::

### Load generation and signing

Open-loop (`rate`) is the default and the realistic model for incoming
federation traffic: requests are launched on schedule regardless of when earlier
responses return, and each request's latency is measured from its scheduled
time (the coordinated-omission correction), so a stalled target shows up as
latency instead of being hidden. Closed-loop (`concurrency`) runs a fixed
number of virtual users. Arrival is `constant` (default) or `poisson`, and
`maxInFlight` caps concurrent in-flight requests.

Signing is kept off the send critical path, set per scenario with `signing`:

- `pipeline` (default): background signers keep a bounded buffer filled, and
buffer starvation surfaces the client as the bottleneck.
- `jit`: sign in the send path, for a strict signature-time-window target.
- `presign`: pre-sign an estimated open-loop run before the timed window
(open-loop only; Poisson arrivals may still sign a few extra during the
run).

### Output

Choose the format with `--format text` (default), `json`, or `markdown`;
`--output` only chooses the destination (a file instead of standard output) and
does not infer the format, so pass both (for example
`--format json --output report.json`). JSON is the canonical machine form: it
validates against the [report schema] and carries
its own `$schema`; the text and Markdown renderers derive from the same model,
keeping client-measured and server-reported numbers distinct. Both sides are
scoped to a measured window: client latency excludes warm-up samples, and the
server-reported numbers are the difference between a `stats` snapshot taken when
the measured window opens and one taken when it closes, so they exclude every
earlier scenario in the suite and the scenario's own warm-up traffic (apart from
warm-up requests still in flight at the boundary, a residue no larger than the
number of requests in flight at that moment). In GitHub Actions, append the
Markdown report to the job summary:

~~~~ sh
fedify bench scenario.yaml --format markdown >> "$GITHUB_STEP_SUMMARY"
~~~~

An `expect` gate that fails exits the command non-zero, so a suite doubles as a
CI check. Keep CI gates on robust signals such as success rate, error counts,
and gross throughput or latency floors; precise latency-percentile regression
belongs in a controlled environment, not a shared CI runner.

[report schema]: https://json-schema.fedify.dev/bench/report-v1.json

### Safety

`fedify bench` runs without friction against a loopback or private target, or
any target that advertises benchmark mode. A public target that does not
advertise benchmark mode is refused unless you pass `--allow-unsafe-target`,
which is mandatory (never prompted) in CI and any non-interactive context. Use
`--dry-run` to print the plan without sending anything.

### Local targets over HTTP

An `inbox` recipient given as an `acct:` handle is resolved through WebFinger,
which goes over HTTPS, so against a plain-HTTP loopback target give the
`recipient` as the actor's URI (for example
`http://localhost:3000/users/alice`) instead. The `webfinger` scenario is
unaffected: it requests `/.well-known/webfinger` on the target directly, so it
can benchmark `acct:` lookups over plain HTTP.

Signed scenarios such as `inbox` make the target dereference the benchmark's
synthetic actor server while verifying signatures, so that server must be
reachable from the target. A loopback target reaches it automatically (both
run on the same machine). For a non-loopback target, pass `--advertise-host`
with an address the target can reach (for example the client's LAN IP); the
synthetic server then binds every interface and advertises that host in the
actor and key URLs. Without it, a non-loopback signed scenario is refused
(use a read scenario such as `webfinger`, which needs no synthetic server).


Benchmark stats endpoint
------------------------

Expand Down
3 changes: 3 additions & 0 deletions packages/cli/deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"license": "MIT",
"exports": "./src/mod.ts",
"imports": {
"@cfworker/json-schema": "npm:@cfworker/json-schema@^4.1.1",
"@hongminhee/localtunnel": "jsr:@hongminhee/localtunnel@^0.3.0",
"@inquirer/prompts": "npm:@inquirer/prompts@^7.8.4",
"@jimp/core": "npm:@jimp/core@^1.6.1",
Expand All @@ -20,6 +21,7 @@
"smol-toml": "npm:smol-toml@^1.6.1",
"srvx": "npm:srvx@^0.8.7",
"valibot": "jsr:@valibot/valibot@^1.4.0",
"yaml": "npm:yaml@^2.9.0",
"#kv": "./src/kv.node.ts"
},
"exclude": [
Expand Down Expand Up @@ -56,6 +58,7 @@
"codegen"
]
},
"generate-bench-schema": "deno run -A scripts/generate-bench-schema.ts",
"test": {
"command": "deno test --allow-all",
"dependencies": [
Expand Down
6 changes: 4 additions & 2 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"test": "node --test --experimental-transform-types 'src/**/*.test.ts' '!src/init/test/**'",
"test-init": "deno task test-init",
"pretest:bun": "pnpm build",
"test:bun": "bun test",
"test:bun": "bun test --timeout 60000",
"run": "pnpm build && node --disable-warning=ExperimentalWarning dist/mod.js",
"runi": "tsdown && node --disable-warning=ExperimentalWarning dist/mod.js",
"run:bun": "pnpm build && bun dist/mod.js",
Expand Down Expand Up @@ -72,6 +72,7 @@
}
},
"dependencies": {
"@cfworker/json-schema": "^4.1.1",
"@fedify/fedify": "workspace:*",
"@fedify/init": "workspace:*",
"@fedify/relay": "workspace:*",
Expand Down Expand Up @@ -109,7 +110,8 @@
"shiki": "^1.6.4",
"smol-toml": "^1.6.1",
"srvx": "^0.8.7",
"valibot": "^1.4.0"
"valibot": "^1.4.0",
"yaml": "^2.9.0"
},
"devDependencies": {
"@types/bun": "catalog:",
Expand Down
28 changes: 28 additions & 0 deletions packages/cli/scripts/generate-bench-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* Regenerates the published benchmark JSON Schema files under the repository's
* *schema/bench/* directory from the embedded schema objects.
*
* The embedded objects (under *packages/cli/src/bench/.../schema.ts*) are the
* editing source; the published *.json* files are the hosted copies. A drift
* guard keeps the two identical, so run this script after editing an embedded
* schema.
*
* Usage: `deno run -A scripts/generate-bench-schema.ts`
* @module
*/

import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { PUBLISHED_SCHEMAS } from "../src/bench/schemas.ts";
import { SCHEMA_DIR, serializeSchema } from "../src/bench/schema-paths.ts";

async function main(): Promise<void> {
await mkdir(SCHEMA_DIR, { recursive: true });
for (const { fileName, schema } of PUBLISHED_SCHEMAS) {
const path = join(SCHEMA_DIR, fileName);
await writeFile(path, serializeSchema(schema), { encoding: "utf-8" });
console.error(`Wrote ${path}`);
}
}

await main();
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# signatureVerification.* is not a valid expect metric for a webfinger scenario.
version: 1
target: http://localhost:3000
scenarios:
- name: webfinger-lookup
type: webfinger
recipient: "acct:alice@example.com"
expect:
signatureVerification.p95: "< 10ms"
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# A failure scenario must declare at least one fault.
version: 1
target: http://localhost:3000
scenarios:
- name: broken
type: failure
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# The top-level version field is required.
target: http://localhost:3000
scenarios:
- name: inbox-shared
type: inbox
recipient: "acct:alice@example.com"
11 changes: 11 additions & 0 deletions packages/cli/src/bench/__fixtures__/invalid/mixed-bad-metric.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# "bogus.metric" is not a recognized metric for a mixed scenario's expect block.
version: 1
target: http://localhost:3000
scenarios:
- name: blend
type: mixed
mix:
- { scenario: inbox-shared, weight: 80 }
- { scenario: webfinger-lookup, weight: 20 }
expect:
bogus.metric: ">= 1"
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# A load block must specify rate XOR concurrency, not both.
version: 1
target: http://localhost:3000
defaults:
load:
rate: 100/s
concurrency: 50
scenarios:
- name: inbox-shared
type: inbox
recipient: "acct:alice@example.com"
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# An actor group must have exactly one HTTP request signature scheme.
version: 1
target: http://localhost:3000
actors:
- signatureStandards: [draft-cavage-http-signatures-12, rfc9421]
scenarios:
- name: inbox-shared
type: inbox
recipient: "acct:alice@example.com"
Loading