Skip to content

perf(downgrader): follow each $ref chain once per conversion - #61

Open
dinwwwh wants to merge 3 commits into
mainfrom
claude/adoring-volta-wg9ppm
Open

dinwwwh wants to merge 3 commits into
mainfrom
claude/adoring-volta-wg9ppm

Conversation

@dinwwwh

@dinwwwh dinwwwh commented Oct 20, 2026 •

Copy link
Copy Markdown
Member

Why

resolve() in packages/downgrader/src/shared.ts followed a $ref chain hop by hop on every call. resolvePointer cached each hop in ctx.targets, but not where the chain ended. It runs once per reference:

  • convertParameterRef (3.2 → 3.1): each parameter or header $ref
  • schemeType (3.1 → 3.0): each securitySchemes entry and each security requirement name

So n references into an n-hop chain did O(n²) work, which breaks the README's Performance claim ("work grows with the size of the document"). A valid 1 MB 3.1 document with 8000 chained security schemes and 8000 operations requiring s0 took about 22.7 s to downgrade. Real documents rarely chain more than one or two hops, so this mostly matters for services that convert untrusted uploads.

Fix

  • Context gets a resolved map, created in downgrade(). It records where the chain from each $ref ends, or undefined if the chain dangles or loops.
  • resolve() stops at the first $ref already in that map. It then records the result for every $ref it passed on the way. Each $ref is walked once per conversion.
  • This is safe because the input is never mutated during a conversion.
8000-hop chain Before After Same document, no chain
3.1 → 3.0, securitySchemes 22,729 ms 181 ms 183 ms
3.2 → 3.1, components.headers 22,776 ms 134 ms 138 ms

From 1,000 to 32,000 paths, chained documents now cost the same as unchained ones, and time roughly doubles when the document doubles.

Benches and tests

  • parameter $ref chain, 1000 hops (3.2 → 3.1): each operation enters the chain at a different point.
  • security scheme $ref chain, 1000 hops (3.1 → 3.0): converting the schemes enters the chain at every hop, and 1000 operations all require the first scheme. The old resolve() is about 15× slower on it (287 ms vs 19 ms locally).
  • Why the requirements all name S0: with 1000 different names, the fixture held 1000 objects whose only key differed. That gave the empty object's hidden class 1000 transitions. After CodSpeed's gc(), the measured call of every other bench in the file reinserted its own transitions into that list, so official corpus, order schema with $defs and property schema showed 12–23% regressions the code didn't cause. Replaying CodSpeed's protocol under callgrind (7 warm-ups, gc(), one measured call), those benches are now within 0.4% of main in instruction count.
  • The three chain bench builders share a refChain helper. The pathItemChainV31 document is unchanged.
  • One test per converter for a chain entered partway along and a cycle reached from outside it. Both tests also pass on the old resolve(), so the memo changes no output.

Validation

  • pnpm test (199 tests), pnpm run bench and pnpm run check pass locally with pnpm 12.4.1.

Not in this PR

Chains of bare $refs that the converter has to replace with their target go through inline(), which doesn't use resolve(). This covers components.mediaTypes in 3.2 → 3.1 and $defs/definitions in 3.1 → 3.0. Those chains are still quadratic: 776 ms and 1,137 ms at 2,000 hops. From about 12,000 hops they throw RangeError: Maximum call stack size exceeded, even with one reference. A fix has to stop at the first $ref that stays a reference in 3.0, and keep today's results for cycles and dangling refs, so it gets its own PR.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SQziHTnKWGkgUCmhHSPqwh

`resolve()` walked a `$ref` chain hop by hop on every call, so a chain of
n parameters, headers, or security schemes referenced n times took O(n²)
work: 8000 chained security schemes with 8000 requirements took ~20 s to
downgrade. Each walk now stops at the first `$ref` already followed and
records where the chain ends, or that it ends in a cycle, for every `$ref`
on the way, so each one is walked once per conversion.

Adds benches for a security scheme chain (3.1 → 3.0) and a parameter
chain (3.2 → 3.1), and tests for chains entered partway and cycles
reached from outside them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQziHTnKWGkgUCmhHSPqwh
Keep `resolve()`'s original `for` header, note that a memoized `undefined`
also means a dangling `$ref`, and build the three chain benches' components
with one `refChain` helper. The bench documents are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQziHTnKWGkgUCmhHSPqwh
@codecov

codecov Bot commented Oct 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@dinwwwh dinwwwh changed the title Optimize $ref resolution by caching chain endpoints perf(downgrader): follow each $ref chain once per conversion Oct 20, 2026
@codspeed

codspeed Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 20 untouched benchmarks
🆕 2 new benchmarks

Performance Changes

Benchmark BASE HEAD Efficiency
🆕 security scheme $ref chain, 1000 hops N/A 76.8 ms N/A
🆕 parameter $ref chain, 1000 hops N/A 77.5 ms N/A

Comparing claude/adoring-volta-wg9ppm (ce9e19d) with main (a204b43)

Open in CodSpeed

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes

This run reviewed the full PR: the resolve() memoization in shared.ts, the new chain/cycle tests, and the benchmark refactor.

  • Memoized $ref chain resolution — Context gains resolved, and resolve() now stops at the first $ref already cached and records the terminal for every $ref on the way, turning the prior O(n²) walk over n references into O(n). downgrade() seeds a fresh map per conversion.
  • Chain/cycle tests — one per converter, covering a chain entered partway and a cycle reached from outside it.
  • Benchmark refactor — shared refChain helper, new 1000-hop securitySchemeChainV31 (3.1→3.0) and parameterChainV32 (3.2→3.1) benches; pathItemChainV31 rebuilt on refChain with identical output.

Soundness of the cache. The $ref graph has out-degree ≤ 1, so the terminal reached from a $ref is a pure function of the ref string and ctx.root. A node on a cycle can only ever be cached as undefined, and a chain merging into a cached node necessarily inherits that node's terminal. I confirmed this both by reasoning through the merge and cycle branches and independently via an exhaustive check over all 5-node successor graphs across every call ordering (no divergences), plus an audit that no code path mutates ctx.root mid-conversion — all delete/setOwn calls target freshly built out objects. pnpm test passes 199/199 on the head commit.

Pullfrog  | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

…s neighbours

Each of the bench's 1000 operations required a different scheme, so the
fixture held 1000 objects whose only key differed, each adding a
transition to the empty object's hidden class. After CodSpeed's gc(), the
measured call of every other bench in the file reinserts its transitions
into that list, which made `official corpus`, `order schema with $defs`
and `property schema` report 12-23% regressions.

Every operation now requires the first scheme, as in the original repro.
The schemes themselves still enter the chain at every hop, so the old
`resolve()` is still about 15x slower on this bench.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQziHTnKWGkgUCmhHSPqwh
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