Skip to content

perf(webapp,run-engine): cut CPU on the engine-facing worker-action routes - #4746

Merged
ericallam merged 5 commits into
mainfrom
perf/engine-worker-action-cpu
Aug 21, 2026
Merged

perf(webapp,run-engine): cut CPU on the engine-facing worker-action routes#4746
ericallam merged 5 commits into
mainfrom
perf/engine-worker-action-cpu

Conversation

@ericallam

@ericallam ericallam commented Aug 21, 2026

Copy link
Copy Markdown
Member

Cuts CPU on the engine/v1/worker-actions/* routes a managed supervisor calls, and adds the benchmark harness the numbers come from.

Measured on a local stack: on-CPU per completed run 9.07ms → 6.59ms (−27%), busy fraction 45.6% → 33.8%, with every worker-action p50 down 23–27%. Load was 5,000 runs / 24 virtual supervisors / 90s window / 30,120 requests / 0 errors.

Query-count work from the same investigation is deliberately not here — it will follow as a separate PR.

The three changes

1. Split the event-loop monitor in two (~14% of on-CPU, plus ~5pp of GC).

eventLoopMonitor.server.ts installs a global async_hooks hook: init writes a Map entry for every async resource the process creates, before calls process.hrtime() and context.active() on every one. Enabling any async hook also puts V8 on the slow path for promise instrumentation process-wide. EVENT_LOOP_MONITOR_ENABLED defaulted to "1", so this was the shipping configuration.

The blocked-loop detector is now opt-in (EVENT_LOOP_MONITOR_ENABLED, default 0). The event-loop utilization gauge — a single interval timer with no per-request cost — moves to its own flag (EVENT_LOOP_UTILIZATION_MONITOR_ENABLED, default 1) and stays on, so the useful half survives without the expensive half.

A/B under identical load:

monitor on monitor off change
on-CPU per run 9.08ms 7.25ms −20%
GC self time 9.80% 5.05% −4.75pp
dequeue p50 76.6ms 62.8ms −18%
attempts/start p50 56.3ms 43.5ms −23%

2. Bucket route matching by first static path segment (10.4% → 3.9% of on-CPU).

patches/@remix-run__router@1.23.3.patch already memoized flattened branches and compiled path regexes. What remained was the linear scan: matchRouteBranch walked the ranked branch list calling matchPath per branch across 521 route files, so every worker-action request paid a scan proportional to the whole route table.

Branches are now indexed by their lowercased leading segment, with one always-considered list for branches whose leading segment is dynamic, splat or optional (and for root/pathless paths). A request walks only its own bucket merged with that list. Route-matching self time dropped 64% (3.6s → 1.3s over a 90s window).

Ordering is preserved exactly: both lists hold indexes into the already rank-sorted branch array and are walked in ascending-index order, so the first match found is the same branch the full scan would have found. Bucketing lowercases on both sides, so case-insensitive matching still resolves and caseSensitive: true routes are still rejected by matchPath itself. A pathname whose own leading segment can't be bucketed falls back to the full scan.

Verified equivalent to the unpatched matcher over 20,050 pathnames (literal, dynamic, splat, optional, case variants, basenames, percent-encoded) with zero mismatches. apps/webapp/test/routeMatchingPatch.test.ts pins the matching semantics rather than the optimisation, so it still passes without the patch.

3. Demote per-heartbeat and per-dequeue info logs to debug.

These are the two highest-rate engine calls and each wrote a synchronous structured log line on every request. Synchronous console writes can block the loop when stdout backs up, which costs more than the ~1.3% CPU share suggests.

The harness

Two benchmarks, neither in the default suite (they run for minutes, attach the V8 profiler, and report numbers rather than assert on them). See apps/webapp/test/bench/README.md.

  • apps/webapp/test/bench/engineHttp.bench.test.ts — spawns a real webapp against throwaway Postgres/Redis containers, seeds a production environment with a promoted managed deployment, and drives a closed-loop supervisor pool through the full lifecycle. Profiling runs over CDP rather than --cpu-prof so it covers only the measured window instead of being swamped by boot, and performance.eventLoopUtilization() is sampled inside the webapp process.
  • internal-packages/run-engine/src/engine/bench/runEngineLifecycle.bench.test.ts — drives RunEngine directly, profiling enqueue and lifecycle separately so engine cost isn't mixed with request-stack overhead.
  • apps/webapp/test/bench/analyzeProfile.ts — dependency-free .cpuprofile analyzer that symbolicates through the build's source maps and ranks CPU by package, self time and total time. Percentages are shares of on-CPU time (V8's (idle)/(program) excluded).

startWebapp gains overrideEnv, applied after the worker-disable defaults, so the HTTP bench can re-enable the run engine worker that drains the master queue into the worker queues a supervisor dequeues from.

The local OTel collector gains a traces pipeline. It only defined a metrics pipeline, so pointing INTERNAL_OTEL_TRACE_EXPORTER_URL at it locally failed and the webapp silently fell back to the console span logger.

Configuration

For operators upgrading:

  • EVENT_LOOP_MONITOR_ENABLED (now defaults to 0) — the per-async-resource blocked-loop detector. Set to 1 to restore the previous behaviour and keep emitting event-loop-blocked spans.
  • EVENT_LOOP_UTILIZATION_MONITOR_ENABLED (new, defaults to 1) — the nodejs.event_loop.utilization gauge. Unchanged in behaviour; it just has its own flag now so it survives turning the detector off.

Notes for review

  • pnpm-lock.yaml changes only because the router patch content changed, which changes its patch hash.
  • One thing the profile ruled out: with a real OTLP collector receiving spans, tracing costs ~1.7% of on-CPU at 100% sampling and ~0.8% at the production rate. Span shipping is not a hidden cost, so nothing here touches it.
  • Caveats on the numbers: a laptop, not production hardware, so DB and Redis latency are unrepresentative (client-side CPU is what's ranked); single webapp process; throughput varies ~5% run to run, which is why the claims rest on on-CPU per run rather than req/s.

Verification

  • 20,050-pathname router equivalence check vs the unpatched matcher, zero mismatches
  • apps/webapp/test/routeMatchingPatch.test.ts (12 cases) passes
  • webapp e2e smoke suite (68 tests) passes through the patched router
  • run-engine suites covering the snapshot/attempt paths pass
  • typecheck, format, lint, knip clean

… paths

Two on-demand benchmarks, neither in the default suite:

- apps/webapp: spawns a real webapp with --inspect against throwaway
  containers, seeds a production environment with a promoted managed
  deployment, and drives a closed-loop supervisor pool through the
  worker-action lifecycle. Profiles over CDP so the profile covers only the
  measured window, and samples event-loop utilization inside the webapp.
- internal-packages/run-engine: drives RunEngine directly, profiling the
  enqueue and lifecycle phases separately so engine cost is not mixed with
  request-stack overhead.

Plus a dependency-free .cpuprofile analyzer that symbolicates through the
build's source maps and ranks CPU by package, by self time and by total time.

startWebapp gains overrideEnv, applied after the worker-disable defaults, so
the HTTP bench can re-enable the run engine worker that drains the master
queue into the worker queues a supervisor dequeues from.
…outes

Three changes from a CPU profile of the worker-action request path, together
taking on-CPU time per completed run from 9.07ms to 6.59ms (-27%) at ~300 req/s,
with every worker-action p50 down 23-27%.

Split the event-loop monitor in two. The blocked-loop detector installs an
async_hooks hook that fires for every async resource the process creates, and
enabling any async hook also puts V8 on the slow path for promise
instrumentation process-wide; it measured ~14% of on-CPU time plus roughly half
of all GC. It is now opt-in via EVENT_LOOP_MONITOR_ENABLED (default 0). The
event-loop utilization gauge is a single interval timer with no per-request
cost, so it moves to EVENT_LOOP_UTILIZATION_MONITOR_ENABLED (default 1) and
stays on.

Bucket route matching by first static path segment. The existing router patch
removed the per-request re-flatten and regex rebuild, but matching was still a
linear scan over the whole 521-route table. Route-matching self time drops 64%.
Ordering is preserved exactly and equivalence was verified over 20,050
pathnames; apps/webapp/test/routeMatchingPatch.test.ts pins the semantics.

Demote the per-heartbeat and per-dequeue info logs to debug. These are the two
highest-rate engine calls and each wrote a synchronous structured log line on
every request.
The collector only defined a metrics pipeline, so pointing
INTERNAL_OTEL_TRACE_EXPORTER_URL at it locally failed and the webapp fell back
to the console span logger. Adds a traces pipeline with the debug exporter,
which accepts and summarises spans - enough to exercise the whole client-side
export path when profiling.
@changeset-bot

changeset-bot Bot commented Aug 21, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: aced947

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai

coderabbitai Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The changes separate webapp event-loop blocking detection from utilization sampling and add independent environment controls. They add CPU and event-loop benchmark suites for the webapp and run-engine, including profiling, load generation, fixture setup, source-map resolution, and profile analysis. The router patch adds branch bucketing and cache layers with route-matching tests. The Collector now exports sampled OTLP traces through a traces pipeline. Successful dequeue and heartbeat logs now use debug level.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 24 functions across 20 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely summarizes the primary CPU reduction for webapp and RunEngine worker-action routes.
Description check ✅ Passed The description is detailed, on-topic, and documents the changes, benchmarks, configuration, caveats, and verification steps.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/engine-worker-action-cpu

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

coderabbitai[bot]

This comment was marked as resolved.

Settle in-flight CDP requests when the inspector socket closes or errors, and
guard the message parse. A webapp that exited mid-run previously left every
send() pending, so the bench hung until the suite timeout with nothing
explaining why; a parse throw inside the listener surfaced as an
uncaughtException and tore down the runner. send() now also rejects instead of
hanging when the socket is already closed.

Unref both event-loop-utilization interval timers so a throw between start and
stop cannot keep a vitest worker alive.

Make eventLoopUtilizationMonitor.enable() idempotent: a second call previously
started another interval and overwrote the only stored cleanup callback,
leaking the first.

Validate --top in the profile analyzer. A non-numeric value produced NaN, and
slice(0, NaN) silently printed empty tables that looked like an empty profile.

Keep the release note behavioural, without the env var name.
coderabbitai[bot]

This comment was marked as resolved.

The baseline reading was fired without awaiting it, so a sampling tick that
landed before that round trip resolved saw no previous reading and recorded a
zero delta. stopEluSampling() compensated by dropping the first sample
unconditionally, which also discarded a genuine measured interval and could
leave a short run with no samples at all.

The baseline is now awaited before the interval starts, so every recorded
sample is a real delta and the drop is gone.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
apps/webapp/test/bench/lib/cdp.ts (2)

184-189: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Prevent ELU callbacks from updating a stopped sampling run.

An evaluateElu() call can start before Line 223 clears the timer and resolve after stopEluSampling() returns. Line 187 then appends a sample after Lines 232-243 calculate the statistics. This can make stats.sampleCount differ from samples.length. A delayed callback can also append an old sample to a later run.

Use a per-run generation token. Invalidate it in stopEluSampling(). Check it after the baseline await and before each callback appends a sample.

Proposed lifecycle guard
 export class WebappProfiler {
+  private eluRun = 0;
   private eluTimer: NodeJS.Timeout | null = null;

   async startEluSampling(intervalMs = 250): Promise<void> {
+    this.stopEluSampling();
+    const run = ++this.eluRun;
     this.eluSamples = [];
     this.eluStartedAt = Date.now();

     await this.evaluateElu();
+    if (run !== this.eluRun) return;

     const timer = setInterval(() => {
       void this.evaluateElu().then((utilization) => {
-        if (utilization !== undefined) {
+        if (run === this.eluRun && utilization !== undefined) {
           this.eluSamples.push({ atMs: Date.now() - this.eluStartedAt, utilization });
         }
       });
     }, intervalMs);

   stopEluSampling(): { stats: EluStats; samples: EluSample[] } {
+    this.eluRun++;
     if (this.eluTimer) {

Also applies to: 221-245


171-194: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add required crumb markers for the changed ELU blocks.

Add an approved // @Crumbs marker or `// `#region` `@crumbs wrapper for the ELU baseline and sample-retention changes.

As per coding guidelines, “Add crumbs as you write code — not just when debugging. Mark lines with // @Crumbs or wrap blocks in `// `#region` `@crumbs.”

Also applies to: 227-227

Source: Coding guidelines


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c89c6ae9-abe0-433a-bb57-12a6ea3475d9

📥 Commits

Reviewing files that changed from the base of the PR and between 733d0f7 and aced947.

📒 Files selected for processing (2)
  • apps/webapp/test/bench/engineHttp.bench.test.ts
  • apps/webapp/test/bench/lib/cdp.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/webapp/test/bench/engineHttp.bench.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 8 remain after this review.

📜 Review details
⏰ Context from checks skipped due to timeout. (47)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (6, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (18, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (17, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (21, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (20, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (14, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (23, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (19, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (15, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (22, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (12, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (11, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (3, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (13, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (4, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (8, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (2, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (24, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (16, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (9, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (10, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (7, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (5, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (1, 24)
  • GitHub Check: sdk-compat / Node.js 22.23 (warp-ubuntu-latest-x64-4x)
  • GitHub Check: sdk-compat / Node.js 20.20 (warp-ubuntu-latest-x64-4x)
  • GitHub Check: e2e / 🧪 CLI v3 tests (warp-windows-latest-x64-8x - npm)
  • GitHub Check: sdk-compat / Node.js 26.4 (warp-ubuntu-latest-x64-4x)
  • GitHub Check: sdk-compat / Bun Runtime
  • GitHub Check: sdk-compat / Node.js 24.18 (warp-ubuntu-latest-x64-4x)
  • GitHub Check: sdk-compat / Cloudflare Workers
  • GitHub Check: e2e / 🧪 CLI v3 tests (warp-ubuntu-latest-x64-4x - pnpm)
  • GitHub Check: sdk-compat / Deno Runtime
  • GitHub Check: e2e / 🧪 CLI v3 tests (warp-windows-latest-x64-8x - pnpm)
  • GitHub Check: e2e / 🧪 CLI v3 tests (warp-ubuntu-latest-x64-4x - npm)
  • GitHub Check: runops-guard / runops-guard
  • GitHub Check: packages / 🧪 Unit Tests: Packages (1, 3)
  • GitHub Check: packages / 🧪 Unit Tests: Packages (2, 3)
  • GitHub Check: obsmap / 🧪 Unit Tests: Observability Map
  • GitHub Check: packages / 🧪 Unit Tests: Packages (3, 3)
  • GitHub Check: internal / 🧪 Unit Tests: Internal
  • GitHub Check: e2e-webapp / 🧪 E2E Tests: Webapp (2, 2)
  • GitHub Check: fk-cascade-guard / fk-cascade-guard
  • GitHub Check: typecheck / typecheck
  • GitHub Check: e2e-webapp / 🧪 E2E Tests: Webapp (1, 2)
  • GitHub Check: code-quality / code-quality
  • GitHub Check: Analyze (javascript-typescript)
🧰 Additional context used
📓 Path-based instructions (6)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

**/*.{ts,tsx}: Use types over interfaces for TypeScript
Avoid using enums; prefer string unions or const objects instead

**/*.{ts,tsx}: Prefer static imports over dynamic imports. Only use dynamic import() when:

  • Circular dependencies cannot be resolved otherwise
  • Code splitting is genuinely needed for performance
  • The module must be loaded conditionally at runtime

Files:

  • apps/webapp/test/bench/lib/cdp.ts
{packages/core,apps/webapp}/**/*.{ts,tsx}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Use zod for validation in packages/core and apps/webapp

Files:

  • apps/webapp/test/bench/lib/cdp.ts
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Use function declarations instead of default exports

Files:

  • apps/webapp/test/bench/lib/cdp.ts
**/*.ts

📄 CodeRabbit inference engine (.cursor/rules/otel-metrics.mdc)

**/*.ts: When creating or editing OTEL metrics (counters, histograms, gauges), ensure metric attributes have low cardinality by using only enums, booleans, bounded error codes, or bounded shard IDs
Do not use high-cardinality attributes in OTEL metrics such as UUIDs/IDs (envId, userId, runId, projectId, organizationId), unbounded integers (itemCount, batchSize, retryCount), timestamps (createdAt, startTime), or free-form strings (errorMessage, taskName, queueName)
When exporting OTEL metrics via OTLP to Prometheus, be aware that the exporter automatically adds unit suffixes to metric names (e.g., 'my_duration_ms' becomes 'my_duration_ms_milliseconds', 'my_counter' becomes 'my_counter_total'). Account for these transformations when writing Grafana dashboards or Prometheus queries

Files:

  • apps/webapp/test/bench/lib/cdp.ts
apps/webapp/**/*.{ts,tsx}

📄 CodeRabbit inference engine (.cursor/rules/webapp.mdc)

apps/webapp/**/*.{ts,tsx}: Access environment variables through the env export of env.server.ts instead of directly accessing process.env
Use subpath exports from @trigger.dev/core package instead of importing from the root @trigger.dev/core path

Files:

  • apps/webapp/test/bench/lib/cdp.ts
**/*

📄 CodeRabbit inference engine (AGENTS.md)

**/*: Add crumbs as you write code — not just when debugging. Mark lines with
// @Crumbs or wrap blocks in `// `#region` `@crumbs. They stay on the
branch throughout development and are stripped by agentcrumbs strip
before merge.
Do not invent new namespaces — pick from this table or ask first.
Do not flag // @Crumbs markers, `#region `@crumbs blocks, or agentcrumbs
imports in reviews. These are temporary debug instrumentation stripped
before merge. Data logged in crumbs (IDs, names, values) never reaches
production.

Files:

  • apps/webapp/test/bench/lib/cdp.ts

@ericallam
ericallam marked this pull request as ready for review August 21, 2026 10:39

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

@ericallam
ericallam enabled auto-merge (squash) August 21, 2026 10:52
@ericallam
ericallam merged commit 60d71da into main Aug 21, 2026
74 checks passed
@ericallam
ericallam deleted the perf/engine-worker-action-cpu branch August 21, 2026 10:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants