Repository navigation
build(cli): Run generators in one process - #1939
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
BYK
left a comment
There was a problem hiding this comment.
I wonder if we can follow the orchestrator-worker pattern and run these in parallel to save more time.
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>
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.
There was a problem hiding this comment.
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
preparehook inscript/lib/command-api-analysis.ts, before the tree loads. command-api'semitmaps the posted result onto the tree.command-apiruns last, so the other steps overlap the worker, including the SDK's own compiler load.- No step reads
command-api.jsonback; onlysentry schemareads 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.tsis committed. Its stamp hashes machine-specificnode_modulespaths, 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>
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ 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.
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>

Part of #1914. Builds on #1923, which has merged, so this targets
main.Before this PR, each
generate:*step ran as its ownpnpm tsxprocess, and seven of them loaded the route tree.generate:docsstarted pnpm ten times and loaded the tree six times.script/generate.tsnow runs the requested steps in one process, in two phases:generate-parsers.mjs), the output-schema field data, and the banner. They read none of each other's outputs, so they run concurrently underPromise.all; the grammar compiles in a child process started withspawn.script/lib/route-tree-stubs.ts. Then load the tree once and run each step's exportedemit()in order: completions, top-level-routes, command-docs, docs-sections, skill, sdk, then command-api.command-apispends about 2.2 s parsing all ofsrc/with the TypeScript compiler, and that needs no tree. Its newpreparehook 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 readssrc/at once, and readsapi-schema.jsononly after the schema step settles. Itsemitmaps the posted result onto the tree, and it runs last so the other steps overlap the worker. No step readscommand-api.jsonback; onlysentry schemareads it, at runtime.Other changes:
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-fragmentsandcheck-doc-examplesuse the shared stub helper.docs-sections --checkis now anemit({ check })option. It setsprocess.exitCodeand no longer callsprocess.exit.package.jsonscripts call the runner directly:generate:docs→tsx script/generate.ts docsgenerate→docs sdk. build, dev, bundle, typecheck and test:unit use this.generate:<step>runs a single step through the runner, for examplegenerate:sdk→tsx script/generate.ts sdk.check:docs-sections→docs-sections --check.generate:grammaris unchanged.@sentry/mcp-server,bundlenow callspnpm --filter sentry run generateand no longer chains three steps.script/lib/step-stamp.tsskips a step when the content hashes of both match the step's entry insrc/generated/.stamps.json(gitignored).package.jsonof each package it reads through (peggy,typescript,pngjs). For the schema step, the input is the SDK's@sentry/api/operationsfile itself.generate:grammarrun on its own or another branch's pipeline.git clean -fdx src/generatedstill regenerates everything.output-schemasand 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.generate:docsincludes the schema step, so these no longer rungenerate:schemabefore it: the docs job incli-build.yml, the rootdeploy:docs, and cli-docs'ci:prepare.scripts/ci-projects.test.mjsassertsci:preparerunsgenerate:docs. I found no other workflow or script that chains them; mcp-server'sbundlealready makes a singlegeneratecall.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.pnpm run generate:docspnpm run generate:sdkpnpm run generate)Step skipping, measured on a warm tree where nothing changed. I alternated
tsx script/generate.ts docsbetween a df7289b worktree and this head, 7 pairs, because separatepnpm runloops varied by about ±0.3 s:tsx script/generate.ts docsschema grammar banner), this head, stamps deleted vs kept, 5 pairsMost of the saving is the grammar step, which starts a separate Node process to run peggy.
The
command-apiworker, 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:docsdocs sdkConcurrent phase 1, with the worker started at run start: c2b0680 against 1161117,
tsx script/generate.ts, 7 runs each. Thedocsanddocs sdkrows alternated the two heads; the phase-1 rows are separate 7-run loops per head. Cold means.stamps.jsondeleted before each run, so every phase-1 step runs:schema grammar output-schemas banner), colddocs sdk, colddocs sdk, warmdocs, warmPhase 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-apiis the exception because its 2.2 s TypeScript pass needs no tree.Verification
src/generated/, the cli-docs pages,.well-known,plugins/and the SDK files, and checked for a cleangit status. They matched in each case:git clean -fdx src/generated; a fully fresh run, including the docs pages and the SDK; throughensure-generated.mjs; andgenerate:sdkalone from a fresh state.main's pipeline (generate:schema && generate:docs && generate:sdk), with step skipping: from a fresh state; a warm run with every stamp skipping; andensure-generated.mjsaftergit clean -fdx src/generated.generate:command-apion its own still works.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.jsonkeeps every step's entry.check-fragmentsandcheck-doc-exampleswrite route-tree stubs without generating the real files.ensure-generated.mjsnow 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, thenensure-generated.mjsnow regenerates it, and the output matches main. Before, a leftover{}top-level-routes.jsonmadesentry helpfail; it now works.touchof 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.tscovers the same cases.check:docs-sectionsexits 1 when a section is stale and 0 when it is clean.check:fragmentsandcheck:doc-examplespass.tscpasses,oxlintreports 0 errors, andoxfmt --checkis clean.test/scriptpasses: 22 files, 128 tests.scripts/ci-projects.test.mjspasses 7/7.mainbecause the sandbox runs as root.bundlepasses (3446.2 KB; main's own changes since added 0.5 KB) and skips all three stamped steps.test:bundlepasses 15/15.via @BYK.
--
View Session in Sentry