Skip to content

fix: quadratic error filtering, dropped errors and validation performance - #217

Merged
alexander-akait merged 7 commits into
mainfrom
claude/filter-errors-quadratic-sibling-dos-1tqe2l
Sep 7, 2026
Merged

fix: quadratic error filtering, dropped errors and validation performance#217
alexander-akait merged 7 commits into
mainfrom
claude/filter-errors-quadratic-sibling-dos-1tqe2l

Conversation

@alexander-akait

Copy link
Copy Markdown
Member

Summary

filterErrors was quadratic in the amount of errors ajv reports, and its lookup used a substring
comparison that silently dropped errors. Fixing both turned into a broader pass over the validation
and error formatting paths, verified against the real sass-loader schema.

Bugs

Quadratic error filtering. For every incoming error filterErrors re-scanned every error kept so
far and rebuilt the accumulated children array. Since allErrors is enabled, ajv reports one error
per 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:

validate(
  { type: "object", properties: { foobar: { type: "string" }, foo: { type: "string" } } },
  { foobar: 1, foo: 1 },
);

only reported configuration.foo. The same happened for { a: { b: … }, b: … }, where /a/b was
swallowed by /b.

RangeError for an array of options. The errors of each entry were spread into the result with
push(...errors), which overflows the call stack once an entry reports enough errors - the caller
got RangeError: Maximum call stack size exceeded instead of a ValidationError.

Changes

  • Errors are indexed by their instance path in a prefix tree that matches json pointer segments, and
    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.
  • findAllChildren no longer slices the children array on every recursion.
  • At most 100 errors of a list are listed in the message, the rest is only counted. A configuration
    with 200 000 invalid values used to produce a 8.9MB message, and a single anyOf error with
    200 000 children a 20MB one. errors still holds every error.
  • process.env.SKIP_VALIDATION is read when the module is loaded rather than on every validation.
  • Smaller things: the SKIP_VALIDATION regular expressions are hoisted, indent skips the common
    single line case, and the display instance path is built without an intermediate array.

Behaviour changes

Two things worth a close look:

process.env.SKIP_VALIDATION is read once, when schema-utils is loaded. Reading a variable
from process.env goes through an interceptor and costs ~250ns, which was 84% of a successful
validation - the validation itself only takes ~20ns. Writing the variable after schema-utils has
been 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 writing
process.env for copies too old to know about it, and share the resolved state through
globalThis[Symbol.for("schema-utils/skipValidation")], so copies of different versions still turn
each other on and off - without the process.env round trip they needed before. That direction is
now 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. No
existing snapshot has more than 100 entries, so nothing in the suite changed.

Results

sass-loader's schema, validated the way loaderContext.getOptions(schema) does, median of 5
processes:

scenario before after
valid options 345ns 41ns 6.06x
valid options, all set 399ns 98ns 3.01x
invalid option name 8.8us 8.0us 1.11x
invalid option types 10.7us 9.2us 1.16x
values failing an anyOf 20.6us 17.9us 1.17x
5000 unknown options 60.9ms 1.2ms 45x

For 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, maxRSS for peak memory:

cpu peak RSS GCs
20 000 errors on one instance path 2.43s -> 0.20s 118MB -> 74MB 391 -> 14
20 000 errors on distinct paths 5.43s -> 0.22s 120MB -> 74MB 395 -> 14
20 000 anyOf failures 22.42s -> 0.39s 185MB -> 108MB 1015 -> 18
one anyOf error, 20 000 children 2.72s -> 0.21s 119MB -> 75MB 394 -> 13

Verification

  • 442 tests and 332 snapshots pass, lint and tsc are clean.
  • The error messages of the four invalid sass-loader cases are byte identical before and after
    (same SHA-256), including the anyOf case that exercises description and link.
  • Differential fuzzing of the old and new filterErrors over 35 302 failing validations found 4
    differences, 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.
  • The scan and the index were compared over 16 400 generated error sets covering 0 to 40 errors and
    every combination of nested, sibling and prefix sharing instance paths: no differences.
  • indent was checked against the regular expression it replaces over 27 993 string and prefix
    combinations.
  • New tests cover the dropped errors, the RangeError, the listing limit, both filterErrors
    paths, 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 allErrors validator for the
success path (slower on valid data), a WeakMap cache for compiled validators (ajv's own cache hit
is 5.7ns), dropping verbose (4.7ms of a 100ms compile), dropping $data (no runtime effect), and
building the message lazily - it was in this branch for a commit and reverted, because the accessor
costs ~2us per reported error and webpack always reads message.

What is left is outside schema-utils: a successful validation is now ~20ns of ajv plus ~20ns of
our own code, and a failing one is dominated by V8 building the Error - Error.stackTraceLimit = 0
drops new ValidationError from 3945ns to 1115ns.

One thing to flag: SkipValidationState and ErrorPathNode end up exported from
declarations/validate.d.ts, since every typedef in that file does. Happy to restructure if you
would rather not grow the public types.

🤖 Generated with Claude Code

https://claude.ai/code/session_013wqLmVHkAGBsWQgXknEQCK


Generated by Claude Code

@linux-foundation-easycla

linux-foundation-easycla Bot commented Sep 7, 2026

Copy link
Copy Markdown

CLA Signed
The committers listed above are authorized under a signed CLA.

One or more co-authors of this pull request were not found. You must specify co-authors in commit message trailer via:

Co-authored-by: name <email>

Supported Co-authored-by: formats include:

  1. Anything <id+login@users.noreply.github.com> - it will locate your GitHub user by id part.
  2. Anything <login@users.noreply.github.com> - it will locate your GitHub user by login part.
  3. Anything <public-email> - it will locate your GitHub user by public-email part. Note that this email must be made public on Github.
  4. Anything <other-email> - it will locate your GitHub user by other-email part but only if that email was used before for any other CLA as a main commit author.
  5. login <any-valid-email> - it will locate your GitHub user by login part, note that login part must be at least 3 characters long.

Alternatively, if the co-author should not be included, remove the Co-authored-by: line from the commit message.

Please update your commit message(s) by doing git commit --amend and then git push [--force] and then request re-running CLA check via commenting on this pull request:

/easycla

@codecov

codecov Bot commented Sep 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.71053% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 95.36%. Comparing base (2c0aedf) to head (66cc620).

Files with missing lines Patch % Lines
src/validate.js 96.46% 4 Missing ⚠️
src/ValidationError.js 97.43% 1 Missing ⚠️
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              
Flag Coverage Δ
integration 95.36% <96.71%> (-0.04%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

alexander-akait and others added 6 commits September 7, 2026 21:04
`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
@alexander-akait
alexander-akait force-pushed the claude/filter-errors-quadratic-sibling-dos-1tqe2l branch from 2ddfd6c to 4a6fb30 Compare September 7, 2026 21:04
`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
@alexander-akait
alexander-akait merged commit 17c4e3a into main Sep 7, 2026
33 checks passed
@alexander-akait
alexander-akait deleted the claude/filter-errors-quadratic-sibling-dos-1tqe2l branch September 7, 2026 21:15
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.

1 participant