Skip to content

build(cli): Run generators in one process - #1939

Merged
BYK merged 12 commits into
mainfrom
byk/cli-shared-generator
Nov 4, 2026
Merged

BYK merged 12 commits into
mainfrom
byk/cli-shared-generator

Conversation

@sentry-junior

@sentry-junior sentry-junior Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Part of #1914. Builds on #1923, which has merged, so this targets main.

Before this PR, each generate:* step ran as its own pnpm tsx process, and seven of them loaded the route tree. generate:docs started pnpm ten times and loaded the tree six times. script/generate.ts now runs the requested steps in one process, in two phases:

  1. Pre-tree steps: the steps whose output the route tree imports. These are the API schema, the grammars (generate-parsers.mjs), the output-schema field data, and the banner. They read none of each other's outputs, so they run concurrently under Promise.all; the grammar compiles in a child process started with spawn.
  2. Tree steps: write the stubs for generated files the tree imports, once, in script/lib/route-tree-stubs.ts. Then load the tree once and run each step's exported emit() in order: completions, top-level-routes, command-docs, docs-sections, skill, sdk, then command-api.
    • Worker: command-api spends about 2.2 s parsing all of src/ with the TypeScript compiler, and that needs no tree. Its new prepare hook starts the pass in a worker thread (script/lib/command-api-analysis.ts) as the run begins, so it overlaps phase 1 and the tree load. The worker loads the analyzer and reads src/ at once, and reads api-schema.json only after the schema step settles. Its emit maps the posted result onto the tree, and it runs last so the other steps overlap the worker. No step reads command-api.json back; only sentry schema reads it, at runtime.

Other changes:

  • Each generator exports emit() and no longer does its work at import time. It imports the tree statically, and the per-script copies of the stub code are gone. check-fragments and check-doc-examples use the shared stub helper.
  • docs-sections --check is now an emit({ check }) option. It sets process.exitCode and no longer calls process.exit.
  • The package.json scripts call the runner directly:
    • generate:docs → tsx script/generate.ts docs
    • generate → docs sdk. build, dev, bundle, typecheck and test:unit use this.
    • generate:<step> runs a single step through the runner, for example generate:sdk → tsx script/generate.ts sdk.
    • check:docs-sections → docs-sections --check.
    • generate:grammar is unchanged.
  • In @sentry/mcp-server, bundle now calls pnpm --filter sentry run generate and no longer chains three steps.
  • Unchanged phase-1 steps are skipped. The schema, grammar and banner steps each list the files they read and write. script/lib/step-stamp.ts skips a step when the content hashes of both match the step's entry in src/generated/.stamps.json (gitignored).
    • It uses content hashes, not mtimes, because checkouts and CI cache restores reset mtimes.
    • Inputs are the step's own script, the source it reads, and the package.json of each package it reads through (peggy, typescript, pngjs). For the schema step, the input is the SDK's @sentry/api/operations file itself.
    • Outputs are hashed too, so the step reruns if something else rewrote them, such as generate:grammar run on its own or another branch's pipeline.
    • Missing outputs never match, so git clean -fdx src/generated still regenerates everything.
    • output-schemas and the tree steps always run. Their inputs are the source modules they import and everything those import, which can't be listed cheaply, and the tree steps' cost is the tree load they share anyway.
  • The API schema is generated once. generate:docs includes the schema step, so these no longer run generate:schema before it: the docs job in cli-build.yml, the root deploy:docs, and cli-docs' ci:prepare. scripts/ci-projects.test.mjs asserts ci:prepare runs generate:docs. I found no other workflow or script that chains them; mcp-server's bundle already makes a single generate call.

Timings

Warm runs in packages/cli, on the same sandbox. Each cell is the median of 5 runs (3 for the chain), with the min–max range in parentheses.

Command Before (#1923 head, 38dd63c) After the single-process runner (df7289b)
pnpm run generate:docs 20.75 s (20.53–21.36) 5.06 s (4.97–5.08)
pnpm run generate:sdk 3.48 s (3.41–3.53) 3.01 s (2.94–3.06)
schema + docs + sdk (old chain → new pnpm run generate) 26.74 s (26.39–27.04) 5.13 s (5.09–5.34)

Step skipping, measured on a warm tree where nothing changed. I alternated tsx script/generate.ts docs between a df7289b worktree and this head, 7 pairs, because separate pnpm run loops varied by about ±0.3 s:

No-op run Before skipping (df7289b) With skipping Saved
tsx script/generate.ts docs 4.44 s (4.26–4.61) 4.03 s (3.76–4.13) about 0.4 s
phase 1 only (schema grammar banner), this head, stamps deleted vs kept, 5 pairs 0.76 s (0.70–0.88) 0.16 s (0.14–0.18) about 0.6 s

Most of the saving is the grammar step, which starts a separate Node process to run peggy.

The command-api worker, measured on a warm no-op tree: 9b05771 against this head, tsx script/generate.ts, 6 alternating runs each. These runs used the 9b05771 head, so they don't line up with the step-skipping table:

No-op run Before the worker (9b05771) With the worker Saved
docs 3.92 s (3.79–4.39) 3.03 s (2.83–3.26) about 0.9 s
docs sdk 3.93 s (3.85–4.09) 3.05 s (2.97–3.28) about 0.9 s

Concurrent phase 1, with the worker started at run start: c2b0680 against 1161117, tsx script/generate.ts, 7 runs each. The docs and docs sdk rows alternated the two heads; the phase-1 rows are separate 7-run loops per head. Cold means .stamps.json deleted before each run, so every phase-1 step runs:

Run Sequential phase 1 (c2b0680) Concurrent phase 1 (1161117) Saved
phase 1 only (schema grammar output-schemas banner), cold 985 ms (953–1062) 687 ms (639–740) about 0.3 s
phase 1 only, warm (stamped steps skip) 392 ms (390–430) 353 ms (349–397) about 0.04 s
docs sdk, cold 3.60 s (3.47–4.01) 3.03 s (2.97–3.56) about 0.6 s
docs sdk, warm 3.17 s (3.01–3.18) 2.98 s (2.77–3.28) about 0.2 s
docs, warm 2.99 s (2.87–3.13) 2.86 s (2.81–3.18) about 0.1 s

Phase 2 stays sequential in one thread. Its emitters take 5–60 ms each once the tree is loaded (about 190 ms together) and are CPU-bound; their file writes are about 50 small files already written with async writeFile. More worker threads would each reload the route tree (about 1.3 s), which is the cost this PR removes. command-api is the exception because its 2.2 s TypeScript pass needs no tree.

Verification

  • Byte-identical output. I compared md5 snapshots of src/generated/, the cli-docs pages, .well-known, plugins/ and the SDK files, and checked for a clean git status. They matched in each case:
    • Against the old pipeline at 38dd63c: a warm run; after git clean -fdx src/generated; a fully fresh run, including the docs pages and the SDK; through ensure-generated.mjs; and generate:sdk alone from a fresh state.
    • Against main's pipeline (generate:schema && generate:docs && generate:sdk), with step skipping: from a fresh state; a warm run with every stamp skipping; and ensure-generated.mjs after git clean -fdx src/generated.
  • Worker. A failure in the worker fails the run with its error. generate:command-api on its own still works.
  • Concurrent phase 1. Each phase-1 step runs on its own from a clean src/generated. A grammar syntax error or a thrown schema step fails the run with exit 1, and a schema failure stops the worker. Concurrent stamp writes are chained, so .stamps.json keeps every step's entry.
  • Leftover stubs. check-fragments and check-doc-examples write route-tree stubs without generating the real files. ensure-generated.mjs now treats a file that still holds its stub (script/lib/route-tree-stub-files.mjs) as missing. For each of the three stubbed files, deleting it, running a checker, then ensure-generated.mjs now regenerates it, and the output matches main. Before, a leftover {} top-level-routes.json made sentry help fail; it now works.
  • Step skipping. A step is skipped after a touch of every input. It reruns after an input edit, after its output is edited or deleted, and after its stamps are deleted. test/script/step-stamp.test.ts covers the same cases.
  • Checks. check:docs-sections exits 1 when a section is stale and 0 when it is clean. check:fragments and check:doc-examples pass.
  • Static checks. tsc passes, oxlint reports 0 errors, and oxfmt --check is clean.
  • Tests.
    • test/script passes: 22 files, 128 tests.
    • scripts/ci-projects.test.mjs passes 7/7.
    • Earlier, on the runner commit, the related CLI tests passed: 24 files, 194 tests. In the sandbox, the only failures are 2 completions tests with permission errors (EACCES/EPERM). They fail the same way on main because the sandbox runs as root.
  • mcp-server. bundle passes (3446.2 KB; main's own changes since added 0.5 KB) and skips all three stamped steps. test:bundle passes 15/15.

via @BYK.

--

View Session in Sentry

sentry-junior Bot and others added 4 commits November 3, 2026 23:15
Closes #1914 (parts 2 and 3; part 1, the manifest, was #1918).

Command output schemas move to build-only `src/output-schemas/<area>.ts`,
one module per top-level command. `script/generate-output-schemas.ts`
(part of `generate:docs`, listed in `ensure-generated.mjs`) imports each
module through tsx, reads every exported valibot `...Schema` with the
field extraction that used to run at startup (now
`script/lib/output-schema-fields.ts`), and writes its fields and
`--fields` paths to the gitignored `src/generated/output-schemas/<area>.ts`
under the same export name.

- Commands import that data as `output.schema` and only types from
  `src/output-schemas/`, so a run loads its own route's field lists and
  none of the schemas. The data is typed `OutputSchemaFields<typeof Schema>`,
  so `SchemaMatches` still checks a command's data against its schema.
- `buildFieldsFlag`, `enrichDocsWithSchema` and `buildCommand` read
  `schema.fields` / `schema.paths`; commands carry `__jsonFields` instead
  of `__jsonSchema`, read by `introspect.ts` and `generate-sdk.ts`.
- Schemas that also validate API responses (`SentryLogSchema`,
  `TransactionListItemSchema`, `ConversationListItemSchema`,
  `DashboardRevisionSchema`) stay put and are re-exported.
- Org-scoped lists pass the item schema (`orgListItemSchema(Entity)`),
  built in `src/output-schemas/`.
- New lint rule `sentry-cli/no-runtime-output-schema-imports` rejects
  value imports of `src/output-schemas/` from runtime code, including
  `import { type X }`, which `verbatimModuleSyntax` keeps.

Help, `help --json`, `--fields` parsing and completion, the skill files
and the docs pages are byte-identical to main; the SDK files differ only
in a comment naming `__jsonFields`.
…-precompute

# Conflicts:
#	packages/cli/package.json

Co-Authored-By: BYK <126780+BYK@users.noreply.github.com>
Every `generate:*` step used to be its own `pnpm tsx` process, and seven
of them loaded the route tree, so `generate:docs` paid pnpm's startup ten
times and loaded the tree six times. `script/generate.ts` now runs the
requested steps in one process, in two phases:

1. Steps the route tree imports: the API schema, the grammars, the
   output-schema field data, and the banner.
2. Stubs for the generated files the tree imports (now written once, in
   `script/lib/route-tree-stubs.ts`), one load of the tree, then each
   tree step's exported `emit()`, with the SDK last.

Each generator exports `emit()` instead of doing its work at import, and
imports the tree statically rather than after its own copy of the stub
code. `docs-sections --check` is an option instead of an argv lookup, and
sets the exit code instead of exiting. The `package.json` scripts call
the runner directly; `generate` (all steps) replaces the three-step chain
in `@sentry/mcp-server`'s `bundle`.

Generated files, docs pages and SDK files are byte-identical to the
previous pipeline, warm and from a clean `src/generated/`.

Part of #1914.

Co-Authored-By: BYK <126780+BYK@users.noreply.github.com>
# Conflicts:
#	packages/cli/package.json
#	packages/cli/script/generate-output-schemas.ts
#	packages/cli/script/generate-sdk.ts

Co-Authored-By: BYK <126780+BYK@users.noreply.github.com>
@vercel

vercel Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
sentry-local Skipped Skipped Nov 4, 2026 1:24pm UTC

Request Review

@BYK BYK left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I wonder if we can follow the orchestrator-worker pattern and run these in parallel to save more time.

sentry-junior Bot and others added 3 commits November 4, 2026 12:19
The schema, grammar, and banner steps reran on every `generate`, though
their inputs rarely change. Each now lists the files it reads and
writes, and `script/lib/step-stamp.ts` skips it when the content hash of
both matches the stamp its last run left in `src/generated/.stamps/`.
Content hashes, not mtimes, which checkouts and CI caches reset. Hashing
the outputs too catches outputs rewritten since, and missing outputs
never match, so `git clean -fdx src/generated` still regenerates all.

`output-schemas` and the route-tree steps keep running: their inputs are
the source they import, which is no short list to hash.

Co-Authored-By: BYK <126780+BYK@users.noreply.github.com>
`generate:docs` runs the schema step itself, so the docs deploy job,
the root `deploy:docs`, and cli-docs' `ci:prepare` no longer run
`generate:schema` before it.

Co-Authored-By: BYK <126780+BYK@users.noreply.github.com>
Co-Authored-By: BYK <126780+BYK@users.noreply.github.com>
Comment thread packages/cli/script/lib/step-stamp.ts Resolved
Comment thread packages/cli/script/lib/step-stamp.ts Resolved
Per review: open the file and handle ENOENT or EEXIST instead of a
separate `existsSync` call. The route-tree stubs are written with the
`wx` flag, in parallel; the stamp helper reads inputs and outputs in
parallel; `.env.local` is loaded directly. `packageManifest` now
resolves `<name>/package.json` rather than walking up the tree.

Co-Authored-By: BYK <126780+BYK@users.noreply.github.com>
Per review: `command-api` parsed all of `src/` with the TypeScript
compiler on the main thread, about 2.2 s and more than the route tree
load. That analysis needs no tree, so the runner now starts it in a
worker thread before loading the tree (a step's new `prepare`), and the
step's `emit` only maps the posted result onto the tree. `command-api`
runs last so the other steps, the SDK's compiler load included, overlap
the worker. Nothing in a run reads `command-api.json` back; only
`sentry schema` does, at runtime.
Per review: one `src/generated/.stamps.json` with an entry per step
replaces the `src/generated/.stamps/` directory of one file per step.

@sentry-junior sentry-junior Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed the review in 9b05771, 5f20061 and c2b0680.

Orchestrator-worker (5f20061). I timed each step first. On a warm run, command-api took about 2.2 s, mostly the TypeScript-compiler pass over all of src/. That is more than the route tree load (about 1.3 s) and every other step combined (about 0.25 s). That pass needs no tree, so:

  • The runner now starts it in a worker thread, through a new per-step prepare hook in script/lib/command-api-analysis.ts, before the tree loads.
  • command-api's emit maps the posted result onto the tree.
  • command-api runs last, so the other steps overlap the worker, including the SDK's own compiler load.
  • No step reads command-api.json back; only sentry schema reads it, at runtime.

I didn't add workers for the remaining tree steps. Each worker would have to load the tree itself, and that load is the cost the single process removes; together they take about 0.25 s. The phase-1 steps are now skipped by their stamps when nothing changed.

No-op timings, comparing 9b05771 with this head over 6 alternating runs of tsx script/generate.ts: docs went from 3.92 s to 3.03 s and docs sdk from 3.93 s to 3.05 s. Output is still byte-identical to main's pipeline, fresh and warm. A failure in the worker fails the run with the worker's error.

Stamp location (c2b0680). The stamps now live in one src/generated/.stamps.json, with one entry per step, instead of a directory of files. I didn't put them in output file names or first lines because:

  • The schema outputs are JSON arrays, so they can't hold a comment.
  • banner-image.ts is committed. Its stamp hashes machine-specific node_modules paths, so a stamp line would churn a tracked file.
  • Output names are fixed because the code imports them by name.

existsSync (9b05771). The stubs are now written with wx in parallel, the stamp helper reads files directly and treats ENOENT as missing, and .env.local is loaded directly.

The four phase-1 steps (schema, grammar, output-schemas, banner) read
none of each other's outputs, so the runner now starts them together and
awaits them with Promise.all. The grammar compiles in a child process
started with spawn instead of execFileSync, so it no longer blocks the
others. Stamp writes to .stamps.json are chained so concurrent steps
don't overwrite each other's entries.

The command-api worker now starts as the run begins rather than after
phase 1. It loads the TypeScript analyzer and reads src/ right away and
reads api-schema.json only once the schema step settles; a schema
failure rejects the analysis and stops the worker.

Phase 1 (7 runs, median): cold 985 -> 687 ms, warm 392 -> 353 ms.
`docs sdk`: cold 3.60 -> 3.03 s, warm 3.17 -> 2.98 s.

Co-Authored-By: BYK <126780+BYK@users.noreply.github.com>

@sentry-junior sentry-junior Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

On running the generators in parallel, pushed as 1161117.

Phase 1 now runs concurrently. schema, grammar, output-schemas and banner read none of each other's outputs (each one runs on its own from a clean src/generated), so the runner starts all four and awaits them with Promise.all. The grammar used to block on execFileSync; it now runs in a child process started with spawn and awaited on exit, and a nonzero exit fails the run. Stamp writes to .stamps.json are chained so concurrent steps don't drop each other's entries. The command-api worker also starts with the run now, not after phase 1: it loads the analyzer and reads src/ at once, then reads api-schema.json when the schema step settles.

Medians of 7 runs, c2b0680 → 1161117:

Run Before After
phase 1 only, cold (.stamps.json deleted) 985 ms 687 ms
phase 1 only, warm 392 ms 353 ms
docs sdk, cold 3.60 s 3.03 s
docs sdk, warm 3.17 s 2.98 s

Phase 2 stays in one thread, with no new workers. Each worker would have to load the route tree again, about 1.3–1.4 s each, and loading it once is the cost this PR removes. Once the tree is loaded, the emitters are CPU-bound and small: completions 13 ms, top-level-routes 5, command-docs 19, docs-sections 52, skill 41, sdk 60, about 190 ms together. A worker can't pay that back. command-api is the one exception and already has its worker: its 2.2 s TypeScript pass over src/ needs no tree.

No async overlap in phase 2 worth taking. The emit writes are about 50 small files (~450 KB, mostly the docs pages), already written with async writeFile. Interleaving emitters would overlap single-digit milliseconds of I/O, so I left them sequential, which also keeps docs-sections --check stopping the run at the first failure.

Output is byte-identical to main's pipeline: fresh, warm, and through ensure-generated.mjs. tsc, oxlint, oxfmt, test/script (128 tests), the mcp-server bundle (3446.2 KB) and test:bundle (15/15) all pass.

check-fragments and check-doc-examples write the route-tree stubs before
they load the tree but never generate the real files. ensure-generated
only checked that each docs output existed, so a leftover `{}`
top-level-routes.json passed the check and `pnpm run cli help` crashed
with "No generated help data".

The stub contents move to script/lib/route-tree-stub-files.mjs, which
both route-tree-stubs.ts and the plain-Node ensure-generated.mjs import.
ensure-generated now treats a file that still holds its stub as missing.
It compares sizes first, so a no-op check stays at ~28 ms.
@BYK
BYK marked this pull request as ready for review November 4, 2026 13:50
@github-actions github-actions Bot added the risk: medium PR risk score: medium label Nov 4, 2026

@cursor cursor 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.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit a449861. Configure here.

@BYK
BYK merged commit 686c6e6 into main Nov 4, 2026
41 checks passed
@BYK
BYK deleted the byk/cli-shared-generator branch November 4, 2026 14:01
BYK added a commit to getsentry/sentry-api-schema that referenced this pull request Nov 5, 2026
Follow-up to [this review
comment](#130 (comment))
on #130. It's the same refactor as getsentry/toolkit#1939, which made
the CLI's generators run in one process.

Before this PR, `build.mjs` ran each of the 12 scripts in `scripts/` as
its own `node` process with `execSync`. Six of them loaded TypeScript
and five parsed the 6 MB spec, each one starting over. Several also had
their own copies of the same patch helpers.

## What changes

- **One process:** each script exports `emit()` and does no work at
import time. `build.mjs` lists the steps in their old order, imports
each one and runs its `emit`.
- **Specs parsed once:** `normalizeSpec` now also returns the spec it
writes. `build.mjs` passes `{ normalizedSpec, sourceSpec }` to every
step, so no step reads a spec from disk.
- Steps that used to fall back to `openapi-derefed.json` when
`openapi-normalized.json` was missing don't need to anymore; that
fallback was only for running a script on its own.
  - No step changes either spec.
- **Shared helpers:** the new `lib/generated-source.mjs` has
`replaceOnce`, `update`, `parse`, `edit` and `srcPath`. The scripts
import them instead of keeping their own copies.
- `replaceOnce` is the stricter version from
`generate-response-contract.mjs`: it takes a string or a RegExp and
reports how many matches it found.
  - `make-tree-shakeable.mjs` now uses it instead of its own loop.

Most of each script's diff is the new indentation from wrapping its body
in `emit`; [hide
whitespace](https://github.com/getsentry/sentry-api-schema/pull/131/files?w=1)
to see the actual changes. I didn't indent lines inside template
literals, so the generated text didn't change.

## Verification

- With a clean `pnpm build`, every file in `src/` and `dist/` is
byte-identical to `main`'s (`diff -r`).
- `pnpm typecheck` passes, and so do all 632 tests in `pnpm test`.
- The step phase (from the first patch through `make-tree-shakeable`)
took 19.1 s and 19.5 s on `main`, and 16.1 s and 13.5 s on this branch,
in one sandbox. The whole build is about 54 s, and Hey API code
generation and `tsc --emitDeclarationOnly` still take most of it.

<!-- junior-request-attribution:start -->
via @BYK.
<!-- junior-request-attribution:end -->

<!-- junior-session-footer:start -->
<!-- junior-conversation-id:local%3Aweb%3Aad43917b182be622bccdd8f7 -->

--

[View Session in
Sentry](https://sentry.sentry.io/explore/conversations/local%3Aweb%3Aad43917b182be622bccdd8f7/?project=4510944073809921)

<!-- junior-session-footer:end -->

Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com>
Co-authored-by: BYK <126780+BYK@users.noreply.github.com>

This branch was previously deployed

1 inactive deployment
Preview — a449861a Deployed Nov 4, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk: medium PR risk score: medium

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant