Skip to content

Commit 2ae62b9

Browse files
committed
feat(dev,perf): add JsRuntime performance harness
1 parent 8e49945 commit 2ae62b9

23 files changed

Lines changed: 1698 additions & 3 deletions

.github/workflows/ci.yml

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,54 @@ jobs:
7373

7474
- name: Clippy
7575
run: cargo clippy --all-targets -- -D warnings
76+
ci-perf:
77+
name: JS Runtime Perf Harness (Linux)
78+
runs-on: ubuntu-latest
79+
timeout-minutes: 20
80+
permissions:
81+
contents: write
82+
steps:
83+
- uses: actions/checkout@v6
84+
with:
85+
ref: ${{ github.ref }}
86+
87+
- name: Install Protoc
88+
uses: arduino/setup-protoc@v3
89+
90+
- name: Install Rust toolchain
91+
run: |
92+
rustup toolchain install stable
93+
rustup override set stable
94+
95+
- name: Cache Dependencies
96+
uses: Swatinem/rust-cache@v2
97+
with:
98+
shared-key: "ci-perf"
99+
cache-all-crates: true
100+
101+
- name: Set Node.js 22.x
102+
uses: actions/setup-node@v6
103+
with:
104+
node-version: 22.x
105+
106+
- name: Run perf harness and record history
107+
run: make perf ANALYZE=1
108+
109+
- name: Upload perf report JSON
110+
uses: actions/upload-artifact@v7
111+
with:
112+
name: perf-report
113+
path: /tmp/perf.json
114+
retention-days: 30
115+
116+
- name: Commit perf history
117+
if: success() && github.ref == 'refs/heads/main'
118+
run: |
119+
git config user.name "github-actions[bot]"
120+
git config user.email "github-actions[bot]@users.noreply.github.com"
121+
git add .perf/history.jsonl
122+
git diff --cached --quiet || git commit -m "chore: update JS runtime perf history [skip ci]" && git push
123+
76124
ci-web-scraper:
77125
name: Web Scraper Build (Linux)
78126
runs-on: ubuntu-latest

.perf/config.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"thresholds": {
3+
"p50": 15,
4+
"p99": 20,
5+
"throughput": 15,
6+
"peakRssDeltaKb": 25
7+
},
8+
"scenarios": [
9+
"cold_start_trivial",
10+
"steady_state_trivial",
11+
"steady_state_extractor",
12+
"concurrent_extractors_8x"
13+
]
14+
}

.perf/history.jsonl

Whitespace-only changes.

AGENTS.md

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
# AGENTS.md
2+
3+
## JS Runtime Performance Harness (`benches/js-runtime-perf/`)
4+
5+
### Overview
6+
7+
Retrack embeds a Deno/V8 runtime to execute user-supplied extractor and formatter scripts.
8+
Retrack keeps a single long-lived worker thread that owns one V8 isolate and receives work
9+
over an `mpsc` channel. This harness measures the latency, throughput, and peak RSS delta
10+
of that runtime so changes to the architecture (context-per-call, pooling, shared HTTP client,
11+
startup snapshots, etc.) can be evaluated with real numbers.
12+
13+
The harness is self-contained: it lives inside the `retrack` workspace, links against the
14+
real `retrack::js_runtime::JsRuntime`.
15+
16+
The harness is **advisory / warn-only**. CI records a new history entry on every push to
17+
`main` and prints a table with per-metric deltas, but it never fails a build on
18+
regressions. Thresholds in `.perf/config.json` only control when warnings are emitted.
19+
20+
### Scenario catalogue
21+
22+
All scenarios use a default `JsRuntimeConfig` with a 10 MiB heap and a 10s execution
23+
budget, matching production settings.
24+
25+
| Scenario | What it measures |
26+
|----------------------------|------------------------------------------------------------------------------------------------------------------------|
27+
| `cold_start_trivial` | Full worker-thread startup: `JsRuntime::init()` + fresh V8 isolate + first script execution, trivial script. |
28+
| `steady_state_trivial` | Serial executions of a trivial script through a single long-lived `JsRuntime`. |
29+
| `steady_state_extractor` | Realistic extractor: decodes a `Uint8Array` response body, parses JSON, filters/maps, re-encodes the result. |
30+
| `concurrent_extractors_8x` | `tokio::spawn` burst of `N` extractor calls sharing one `Arc<JsRuntime>`; exposes the single-worker-thread bottleneck. |
31+
32+
The last scenario is deliberately designed to show that Retrack's current mpsc-based
33+
architecture serialises concurrent work onto one worker thread, which is the exact shape
34+
of the bottleneck we want any future optimisation to address.
35+
36+
### Running locally
37+
38+
```bash
39+
# Full run + comparison table + history append (from components/retrack/)
40+
make perf ANALYZE=1
41+
42+
# Run only, no history touch (useful when iterating locally and discarding results)
43+
make perf
44+
45+
# Re-analyze an existing /tmp/perf.json (e.g. downloaded from CI) without rerunning
46+
make perf-analyze
47+
48+
# Smoke test (fast)
49+
make perf ANALYZE=1 PERF_ITERATIONS=20 PERF_WARMUP=5
50+
51+
# Single scenario
52+
make perf ANALYZE=1 PERF_SCENARIOS=steady_state_extractor
53+
54+
# Custom output path
55+
make perf PERF_OUTPUT=/tmp/perf-baseline.json
56+
57+
# View HTML report (opens scripts/perf-report.html, then load .perf/history.jsonl)
58+
make perf-report
59+
```
60+
61+
`make perf` produces `/tmp/perf.json` and prints a one-line summary per scenario. When
62+
`ANALYZE=1` is set it then invokes `scripts/analyze-perf.ts`, which compares the fresh
63+
report to the last entry in `.perf/history.jsonl`, prints a table with Δp50/Δp99/Δops/Δrss
64+
columns, and appends to history **only when at least one tracked metric moved by more
65+
than 0.1 %** (see "History append gating" below). `make perf-analyze` is the same
66+
analyze-only tail, exposed separately for re-analyzing a file without rerunning the
67+
harness.
68+
69+
### Interpreting the output
70+
71+
The printed table uses the last recorded history entry as the baseline:
72+
73+
```
74+
Scenario p50 p99 throughput rss Δp50 Δp99 Δops Δrss
75+
steady_state_extractor 1.45ms 1.82ms 688.9/s 512KB -2.1% -3.0% +1.4% 0.0%
76+
```
77+
78+
- **Δp50 / Δp99**: percentage change in latency vs the previous run. Warnings fire when
79+
these exceed the thresholds in `.perf/config.json` (`p50`, `p99`).
80+
- **Δops**: percentage change in throughput. Warnings fire on a _decrease_ below
81+
`-thresholds.throughput` (i.e. getting slower).
82+
- **Δrss**: percentage change in peak RSS delta. Warnings fire above
83+
`thresholds.peakRssDeltaKb`.
84+
85+
A first run prints "First run recorded - no comparison available." and establishes the
86+
baseline.
87+
88+
### History append gating
89+
90+
`scripts/analyze-perf.ts` does not append unconditionally. It diffs the fresh report
91+
against the last entry in `.perf/history.jsonl` across a whitelist of tracked metrics
92+
(`p50_us`, `p90_us`, `p99_us`, `max_us`, `throughput_ops_per_sec`, `peak_rss_delta_kb`).
93+
If every tracked metric on every scenario is within ±0.1 % of the previous entry, the
94+
file is left untouched and the CLI prints `All tracked metrics within ±0.1% of the
95+
previous run; history not updated.` When something moves, the append happens and the
96+
output names the scenario/metric that tripped the threshold.
97+
98+
This matters for the CI commit step: because `history.jsonl` is modified only on
99+
material movement, the `git diff --cached --quiet || git commit` check becomes an
100+
effective "commit only if something changed" — pushes with steady-state numbers no
101+
longer produce noisy chore commits on `main`.
102+
103+
The threshold is hard-coded at `HISTORY_APPEND_THRESHOLD_PCT = 0.1` in
104+
`scripts/analyze-perf.ts`. Adjust there if it proves too tight or too loose.
105+
Scenario additions/removals are treated as unconditionally material (always appended).
106+
Structural zero-valued metrics (e.g. `peak_rss_delta_kb = 0`) are handled explicitly —
107+
`0 → 0` is unchanged, `0 → anything` or `anything → 0` triggers an append.
108+
109+
### CI contract
110+
111+
- `.github/workflows/ci.yml` has a `ci-perf` job that runs on every push to `main`.
112+
- It builds the harness in release mode, runs `make perf ANALYZE=1` (which produces
113+
the report, prints the delta table, and appends to history only on material
114+
movement), uploads `/tmp/perf.json` as an artefact, and commits the updated
115+
`.perf/history.jsonl` back to `main` with `[skip ci]` in the commit message.
116+
- The commit step is a no-op when nothing moved — `history.jsonl` is unmodified, so
117+
`git diff --cached --quiet` is true.
118+
- The job **never fails on regressions**. Warnings are visible in the job log; acting on
119+
them is a human decision.
120+
121+
### File locations
122+
123+
```
124+
benches/js-runtime-perf/Cargo.toml # Workspace member, depends on `retrack` + `retrack-types`
125+
benches/js-runtime-perf/src/main.rs # CLI driver
126+
benches/js-runtime-perf/src/measure.rs # hdrhistogram recorder, peak RSS probe
127+
benches/js-runtime-perf/src/report.rs # JSON output shape (camelCase top-level)
128+
benches/js-runtime-perf/src/scenarios/*.rs # One scenario per file
129+
benches/js-runtime-perf/scripts/*.js # JS fixtures loaded via `include_str!`
130+
src/lib.rs # Minimal library target exposing `js_runtime` + `config`
131+
.perf/config.json # Scenario list + warning thresholds
132+
.perf/history.jsonl # Append-only history (one JSON per run)
133+
scripts/analyze-perf.ts # Node 22 analyzer (reads /tmp/perf.json)
134+
scripts/perf-report.html # Standalone HTML viewer for history.jsonl
135+
```
136+
137+
### Tuning
138+
139+
- To relax or tighten warnings, edit `.perf/config.json`. Values are percentages.
140+
- To add a scenario: create a module under `benches/js-runtime-perf/src/scenarios/`,
141+
register it in `scenarios.rs` (both the `ALL` slice and the `run` dispatcher), and add
142+
its name to `.perf/config.json`.
143+
- Benchmark results are platform-sensitive. History entries include `env.os`, `env.arch`,
144+
and `env.cpuModel` for this reason; absolute numbers from a laptop are not directly
145+
comparable to those from a CI runner.

Cargo.lock

Lines changed: 46 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,17 @@ authors = ["Aleh Zasypkin <dev@retrack.dev>"]
55
description = "Tracks changes in a web page, API, or file."
66
edition = "2024"
77

8+
[lib]
9+
name = "retrack"
10+
path = "src/lib.rs"
11+
812
[[bin]]
913
name = "retrack"
1014
path = "src/main.rs"
1115

1216
[workspace]
1317
members = [
18+
"benches/js-runtime-perf",
1419
"components/retrack-types"
1520
]
1621

Makefile

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ COMPOSE_DB := dev/docker/docker-compose.yml
22
ENV_FILE := .env
33
CHROME_PATH ?= /Applications/Google Chrome.app/Contents/MacOS/Google Chrome
44

5-
.PHONY: dev-up dev-down api scraper-setup scraper scraper-debug db-reset db-migrate test test-api test-scraper fmt clippy check docker-api docker-scraper docker-scraper-camoufox docker-pin-digests clean help
5+
.PHONY: dev-up dev-down api scraper-setup scraper scraper-debug db-reset db-migrate test test-api test-scraper fmt clippy check docker-api docker-scraper docker-scraper-camoufox docker-pin-digests clean help perf perf-analyze perf-report
66

77
## ---------- Development ----------
88

@@ -82,6 +82,31 @@ docker-scraper-camoufox: ## Build the Web Scraper (Camoufox/Firefox) Docker imag
8282
docker-pin-digests: ## Re-pin base images in Dockerfiles to current SHA256 digests.
8383
./dev/scripts/docker-pin-digests.sh
8484

85+
## ---------- JS Runtime Perf Harness ----------
86+
87+
PERF_OUTPUT ?= /tmp/perf.json
88+
PERF_ITERATIONS ?= 500
89+
PERF_WARMUP ?= 50
90+
PERF_CONCURRENCY ?= 8
91+
PERF_SCENARIOS ?= all
92+
93+
perf: ## Run the JS runtime perf harness. Use ANALYZE=1 to also print the comparison table and record to .perf/history.jsonl (ARGS='--scenarios cold_start_trivial --iterations 100').
94+
cargo run --release -p js-runtime-perf -- \
95+
--scenarios $(PERF_SCENARIOS) \
96+
--iterations $(PERF_ITERATIONS) \
97+
--warmup $(PERF_WARMUP) \
98+
--concurrency $(PERF_CONCURRENCY) \
99+
--output $(PERF_OUTPUT) $(ARGS) \
100+
$(if $(ANALYZE),&& node scripts/analyze-perf.ts $(PERF_OUTPUT))
101+
102+
perf-analyze: ## Analyze an existing $(PERF_OUTPUT) without rerunning the harness (equivalent to the ANALYZE=1 tail of `make perf`).
103+
node scripts/analyze-perf.ts $(PERF_OUTPUT)
104+
105+
perf-report: ## Open the HTML perf viewer. Load .perf/history.jsonl inside it.
106+
@open scripts/perf-report.html 2>/dev/null || \
107+
xdg-open scripts/perf-report.html 2>/dev/null || \
108+
echo 'Open scripts/perf-report.html in your browser'
109+
85110
## ---------- Misc ----------
86111

87112
clean: ## Remove build artifacts.

benches/js-runtime-perf/Cargo.toml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
[package]
2+
name = "js-runtime-perf"
3+
version = "0.1.0"
4+
edition = "2024"
5+
publish = false
6+
description = "Performance harness for the Retrack JS runtime."
7+
8+
[[bin]]
9+
name = "js-runtime-perf"
10+
path = "src/main.rs"
11+
12+
[dependencies]
13+
anyhow = "1.0.102"
14+
clap = { version = "4.6.0", features = ["derive"] }
15+
futures = "0.3.32"
16+
hdrhistogram = "7.5.4"
17+
http = "1.4.0"
18+
libc = "0.2.185"
19+
retrack = { path = "../.." }
20+
retrack-types = { path = "../../components/retrack-types" }
21+
serde = { version = "1.0.228", features = ["derive"] }
22+
serde_json = "1.0.149"
23+
tokio = { version = "1.50.0", features = ["macros", "rt", "rt-multi-thread", "sync", "time"] }
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// Realistic extractor: decodes the first response body (Uint8Array), parses
2+
// JSON, filters items, and re-encodes the result. Exercises the same code
3+
// paths a production extractor script would.
4+
(() => {
5+
const response = context.responses[0];
6+
const payload = JSON.parse(Deno.core.decode(new Uint8Array(response.body)));
7+
const filtered = (payload.items || [])
8+
.filter((item) => item.value > 10)
9+
.map((item) => ({ id: item.id, value: item.value * 2 }));
10+
return { body: Deno.core.encode(JSON.stringify({ status: response.status, filtered })) };
11+
})();
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// Minimal extractor-shaped script: returns an empty body envelope, just
2+
// enough for ExtractorScriptResult to deserialise. Used by the
3+
// cold-start and steady-state trivial scenarios.
4+
(() => ({ body: Deno.core.encode("{}") }))();

0 commit comments

Comments
 (0)