fix: quadratic error filtering, dropped errors and validation performance - #217
Conversation
|
One or more co-authors of this pull request were not found. You must specify co-authors in commit message trailer via: Supported
Alternatively, if the co-author should not be included, remove the Please update your commit message(s) by doing |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #217 +/- ##
==========================================
- Coverage 95.40% 95.36% -0.04%
==========================================
Files 9 9
Lines 848 949 +101
Branches 360 387 +27
==========================================
+ Hits 809 905 +96
- Misses 35 40 +5
Partials 4 4
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
`filterErrors` re-scanned every already collected error for each new error
and re-copied the accumulated children on every step, so the cost grew
quadratically with the amount of errors reported by ajv. Since `allErrors`
is enabled, an invalid configuration produces one error per offending
value, which makes it cheap to turn a large invalid options object into a
long busy loop: validating an object with 40000 unknown properties took
~8.5s, and 40000 bad array items took ~90s.
Errors are now indexed by their instance path in a prefix tree, and the
children of an absorbed error are adopted instead of copied, which makes
the same inputs take ~70ms and ~460ms.
The lookup used `oldError.instancePath.includes(instancePath)`, which also
matched instance paths that merely contain the path as a substring, so an
error was nested under - and for most keywords silently swallowed by - an
unrelated error. Two sibling properties where one name is a prefix of the
other were enough to lose an error:
validate(
{ type: "object", properties: { foobar: { type: "string" }, foo: { type: "string" } } },
{ foobar: 1, foo: 1 },
);
only reported `configuration.foo`. The prefix tree matches json pointer
segments, so nesting now happens only for errors that really are reported
for the instance path or for something inside it.
`findAllChildren` sliced the children array on every recursion, which was
quadratic as well; it now takes the end of the range to look at instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wqLmVHkAGBsWQgXknEQCK
- `validate` spread the errors of each entry of an array of options into the result with `push(...errors)`, which throws `RangeError: Maximum call stack size exceeded` instead of a `ValidationError` once an entry reports enough errors. The errors are pushed one by one now, which also drops the intermediate array `map` allocated for every entry. - `needValidate` read `process.env.SKIP_VALIDATION` twice and built both of its regular expressions on every call. Reading a variable from `process.env` is expensive and this runs on every validation, so the value is read once and the regular expressions are hoisted: setting `SKIP_VALIDATION` explicitly made a successful validation ~2.4x slower than leaving it unset, now it costs about the same (582ns -> 365ns). - `filterErrors` walked the instance path prefix tree twice per error, once to collect the errors it nests and once to store the error itself. Both are a single walk now, which also removes the pruning of emptied nodes: the error that is stored keeps the node it is stored in non empty. Nodes allocate their children map only when they get children, and the instance path is split once per run of errors sharing it. - `indent` ran a regular expression with a lookahead over every formatted error, and `formatValidationError` built the pretty instance path through an intermediate array and a per-segment regular expression. Formatting a large amount of errors is ~1.5x faster, a successful validation is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wqLmVHkAGBsWQgXknEQCK
A configuration with a lot of invalid values produced an unreadable and
very large message: 200000 invalid values ended up as a 8.9MB message,
and a single `anyOf` error with 200000 children as a 20MB one, because
every error was listed. At most 100 errors of a list are listed now, the
rest is only counted:
- configuration.list[99] should be a string.
- and 199900 more errors
`errors` still holds every error, so nothing is lost for consumers that
read them instead of the message.
The message is also built on the first access of `message` rather than in
the constructor. Formatting is by far the most expensive part of an
invalid configuration and consumers that only look at `errors` never need
it. The accessor is replaced by a plain property once the message is
built, so `message` keeps behaving like the `message` of any other error -
own, enumerable, writable, and part of the stack. It comes from one shared
descriptor because defining it per error allocated a context and two
functions for every error object, which was slower than building the
message eagerly.
For 200000 errors: 513ms and 120MB -> 325ms and 96MB when the message is
read, 440ms -> 257ms when only `errors` are read.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wqLmVHkAGBsWQgXknEQCK
Benchmarking the real `sass-loader` schema showed that building the message on the first access of `message` costs more than it saves. Defining the accessor and replacing it by a plain property once the message is read adds about 2us to every reported error, which is a 3-15% regression for the amount of errors a loader actually reports: invalid option name 14.0us -> 17.2us invalid option types 18.1us -> 19.8us Listing only the first errors of a list already removed most of the formatting it was meant to avoid, so what is left to save is small and only applies to consumers that never read `message` - `webpack` prints it, so it always does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wqLmVHkAGBsWQgXknEQCK
The instance path index that keeps `filterErrors` from going quadratic costs more than it saves for the handful of errors a loader or plugin usually reports - it allocates a node, a map and a few arrays per error where a direct scan only compares strings. The errors are scanned directly up to `MAX_SCANNED_ERRORS` and indexed above it, which is where the two were measured to cross: errors 1 3 8 24 32 scan 72ns 174ns 557ns 2201ns 5018ns index 232ns 567ns 1530ns 3751ns 5707ns Both nest errors identically, checked over 16400 generated error sets covering 0 to 40 errors and every combination of nested, sibling and prefix sharing instance paths. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wqLmVHkAGBsWQgXknEQCK
…ery validation Reading a variable from `process.env` goes through an interceptor and costs about 250ns, which was 84% of a successful validation against the `sass-loader` schema - the validation itself only takes ~20ns. It is read once now, when the module is loaded, which is how it is used in practice: the variable is set before the process starts. valid options, sass-loader schema 345ns -> 41ns valid options, all options set 399ns -> 98ns `enableValidation()`/`disableValidation()` still take effect immediately. They keep writing `process.env` for copies of `schema-utils` too old to know about it, and share the resolved state through the global object, so copies of different versions still turn each other on and off - without the `process.env` round trip they needed before. Writing `process.env.SKIP_VALIDATION` after `schema-utils` has been loaded no longer has an effect on the copies that are already loaded, which the README now says. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wqLmVHkAGBsWQgXknEQCK
2ddfd6c to
4a6fb30
Compare
`npm audit` reports 4 high severity advisories for `fast-uri` 3.0.0 - 3.1.5, which `ajv` depends on, so the `Security audit` step of the lint job fails. `ajv` accepts `^3.0.0`, so only the lock file changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wqLmVHkAGBsWQgXknEQCK
Summary
filterErrorswas quadratic in the amount of errorsajvreports, and its lookup used a substringcomparison that silently dropped errors. Fixing both turned into a broader pass over the validation
and error formatting paths, verified against the real
sass-loaderschema.Bugs
Quadratic error filtering. For every incoming error
filterErrorsre-scanned every error kept sofar and rebuilt the accumulated children array. Since
allErrorsis enabled,ajvreports one errorper offending value, so a large invalid options object was enough to lock up the process: an object
with 40 000 unknown properties took ~8.5s, and 40 000 bad array items took ~90s.
Dropped errors. The lookup was
oldError.instancePath.includes(instancePath)- a substring test,not a path test. Errors were nested under, and for most keywords silently swallowed by, unrelated
errors. Two sibling properties where one name is a prefix of the other were enough to lose one:
only reported
configuration.foo. The same happened for{ a: { b: … }, b: … }, where/a/bwasswallowed by
/b.RangeErrorfor an array of options. The errors of each entry were spread into the result withpush(...errors), which overflows the call stack once an entry reports enough errors - the callergot
RangeError: Maximum call stack size exceededinstead of aValidationError.Changes
the children of an absorbed error are adopted rather than copied. Below 24 errors the collected
errors are scanned directly, which is cheaper than indexing them - that is where the two were
measured to cross.
findAllChildrenno longer slices the children array on every recursion.with 200 000 invalid values used to produce a 8.9MB message, and a single
anyOferror with200 000 children a 20MB one.
errorsstill holds every error.process.env.SKIP_VALIDATIONis read when the module is loaded rather than on every validation.SKIP_VALIDATIONregular expressions are hoisted,indentskips the commonsingle line case, and the display instance path is built without an intermediate array.
Behaviour changes
Two things worth a close look:
process.env.SKIP_VALIDATIONis read once, whenschema-utilsis loaded. Reading a variablefrom
process.envgoes through an interceptor and costs ~250ns, which was 84% of a successfulvalidation - the validation itself only takes ~20ns. Writing the variable after
schema-utilshasbeen loaded no longer affects copies that are already loaded; the README now says to set it before
starting the process.
enableValidation()/disableValidation()still take effect immediately. They keep writingprocess.envfor copies too old to know about it, and share the resolved state throughglobalThis[Symbol.for("schema-utils/skipValidation")], so copies of different versions still turneach other on and off - without the
process.envround trip they needed before. That direction isnow covered by a test it never had.
Only the first 100 errors of a list are listed. The rest is counted as
and N more errors. Noexisting snapshot has more than 100 entries, so nothing in the suite changed.
Results
sass-loader's schema, validated the wayloaderContext.getOptions(schema)does, median of 5processes:
anyOfFor the last one peak RSS goes 145MB -> 91MB and GC time 8064ms -> 194ms, with 24 627 collections
down to 226.
Larger synthetic cases, fresh process each,
maxRSSfor peak memory:anyOffailuresanyOferror, 20 000 childrenVerification
lintandtscare clean.sass-loadercases are byte identical before and after(same SHA-256), including the
anyOfcase that exercisesdescriptionandlink.filterErrorsover 35 302 failing validations found 4differences, all of them the dropped error above - the new output never loses a line and gains one
in 322 of 29 173 cases with prefix sharing property names.
every combination of nested, sibling and prefix sharing instance paths: no differences.
indentwas checked against the regular expression it replaces over 27 993 string and prefixcombinations.
RangeError, the listing limit, bothfilterErrorspaths, and the state shared between copies. Five of them fail on
main.Things I looked at and did not change
Measured and rejected, so they do not have to be tried again: a non
allErrorsvalidator for thesuccess path (slower on valid data), a
WeakMapcache for compiled validators (ajv's own cache hitis 5.7ns), dropping
verbose(4.7ms of a 100ms compile), dropping$data(no runtime effect), andbuilding the message lazily - it was in this branch for a commit and reverted, because the accessor
costs ~2us per reported error and
webpackalways readsmessage.What is left is outside
schema-utils: a successful validation is now ~20ns ofajvplus ~20ns ofour own code, and a failing one is dominated by V8 building the
Error-Error.stackTraceLimit = 0drops
new ValidationErrorfrom 3945ns to 1115ns.One thing to flag:
SkipValidationStateandErrorPathNodeend up exported fromdeclarations/validate.d.ts, since every typedef in that file does. Happy to restructure if youwould rather not grow the public types.
🤖 Generated with Claude Code
https://claude.ai/code/session_013wqLmVHkAGBsWQgXknEQCK
Generated by Claude Code