Repository navigation
perf(downgrader): tell a schema met again mid-cycle by the conversions still running - #328
Conversation
…s still running #319 marked every converted schema done in a WeakSet, so a schema met again before that counts as loose. Adding each output to a WeakSet made 3.1 to 3.0 about 45% slower than 0.5.0 on real oRPC documents. convertObject now keeps the outputs whose conversion is running on a per-call stack, and calls `revisit` only for one still on it. The output is the same: an output is on the stack exactly while it is not done. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VVaw7PYvCYrJkB3NF1rgbx
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Merging this PR will improve performance by ×2.52 warnings
Performance Changes
Tip Curious why performance improved? Comment Comparing Footnotes
|
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- Cycle detection moved from
DONEWeakSet to aContext.convertingstack —convertObjectpushes each output just afterseen.setand pops it afterfinish, and the memoized-hit branch loosens only when the met-again output is still on that stack;DONEand itsfinishSchemawrite are removed. revisitSchemanow loosens unconditionally — the on-stack filter inconvertObjectreplaces the old!DONE.has(out)guard.
I traced the equivalence: the old "missing from DONE" window runs from output creation to the end of finishSchema (where DONE.add sat), and the new on-stack window runs from just after seen.set to just after finish, with no callback in between — so ctx.converting.includes(known) matches !DONE.has(known) at every memoized hit. revisitSchema is only supplied by convertSchema, and SCHEMA_FIELDS is only used there, so the memoized known is always a schema output finished by finishSchema, which is exactly what the old check assumed. The stack is shared across inlined { ...ctx, seen } contexts by reference, preserving the old WeakSet's context-wide reach, and push/pop stay balanced except on throw, where the per-call Context is discarded. vitest run packages/downgrader passes (302 tests), and no stale DONE references remain.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

Requested by dinwwwh · project thread
Summary
Found while checking main against oRPC before the next release. Since 0.5.0, 3.1 → 3.0 runs about 45% slower on real oRPC documents, and most of that comes from #319. #319 added every converted schema to a
DONEWeakSet, so that a schema met again before it was done counts as loose. A CPU profile showsfinishSchemaself time doubling and more GC, because each output object goes into a WeakSet.This PR keeps the same rule but tracks it differently.
convertObjectkeeps the outputs whose conversion is running on a stack in the call'sContext(pushed when converting starts, popped afterfinish). It callsrevisitonly for an output that is still on that stack.revisitSchemathen always loosens, andDONEis removed.The output does not change. An output is on the stack exactly while its conversion (including
finishSchema) is running, which is exactly when it was missing fromDONE. The stack lives in the per-callContext, so nothing outlives a call, even one that throws.Measurements
3.1 → 3.0 over 66 real oRPC documents (oRPC's own tests plus Zod 4, Valibot, ArkType and Effect 4 routers), median ms per pass over 8 interleaved processes, Node 22:
The remaining gap to 0.5.0 is spread across #309 to #318 (each about 0.3 to 1 ms, inside the noise).
Test plan
pnpm run check,pnpm test(361 pass), coverage still 100%.FUZZ_WIDEon and off, 4 seeds) plus the 66 oRPC documents: 0 differences. The same harness finds 737 to 3,164 differences for two mutants (revisit never loosens, or always loosens), so it does reach cycles.Generated by Claude Code