Skip to content

Validate required Hash params scopes without the attributes iterator - #2927

Closed
ericproulx wants to merge 1 commit into
masterfrom
perf/root-scope-validation-fast-path
Closed

Validate required Hash params scopes without the attributes iterator#2927
ericproulx wants to merge 1 commit into
masterfrom
perf/root-scope-validation-fast-path

Conversation

@ericproulx

@ericproulx ericproulx commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Most validators of most endpoints sit on the root params scope or a required Hash scope under it, and for them Validators::Base ran the generic machinery: scope.should_validate?, which resolves the scope's params and walks the parent chain, then an AttributesIterator pass that resolves the params again, checks for arrays and the empty-optional sentinel and yields once per attribute, then a block that re-checks the scope's requiredness and dependency. For a root-scope validator that plumbing took about 570 of its 840 ns; a nested one paid more.

When a scope and every one of its ancestors is required and depends on nothing, should_validate? is true for every request and has no side effects. ParamsScope#always_validated? answers that. If the scope also does not iterate elements and its params resolve to a Hash, the iterator path reduces to: for each attribute, validate_param!(attr, params) if it is required or present. So:

  • #validate skips should_validate? for such a scope.
  • #validate! resolves the scope's params once and runs that loop (#validate_attributes!).
  • Array scopes, given, optional scopes and their descendants, and params that resolve to something other than a Hash keep the existing path. DefaultValidator and the multiple-params validators, which override #validate!, only skip should_validate?.
  • The flag is set in #initialize, since validators are frozen once built; a scope's parent, optionality, dependency and type are fixed before its block declares anything.

Per validator: root presence 658 → 311 ns, root coerce 728 → 406 ns, nested coerce 1.77 → 1.02 µs.

Benchmarks

Median of 7 interleaved subprocess rounds against master, Ruby 4.0.6, no JIT:

request delta
JSON POST, 5 typed root params +20.5%
JSON POST with a required nested Hash (3 attributes) +19.3%
GET, 3 query params +11.5%
GET, pagination helper with values and defaults, mutually_exclusive (10 validators) +11.1%
POST returning declared(params), optional nested Hash +6.5%
GET with a before filter and a typed route param +4.4%
GET with no params (control) +0.2% (noise)

Under YJIT, an earlier root-only version of this measured +11.3% on the JSON POST, +12.0% on the 10-validator GET and +7.3% on the 3-param GET.

Missing spec, added

An Array group handed a Hash reports only its own type error; its members are not validated against the Hash as though it were one element. Nothing pinned that: sending such a scope down the new path passed the whole suite while adding items[id] is missing to the error. The new spec passes before and after this change and fails in that case.

Behaviour

Byte-identical to master over a 104-case validation matrix, under both the HashWithIndifferentAccess and Hash params builders: every built-in validator (values, except_values, regexp, length, same_as, allow_blank, default, as, mutually_exclusive, all_or_none_of, at_least_one_of, given, fail_fast, a custom validator), required and optional nested Hashes three levels deep, a with group inside a nested Hash, a String or an Array sent where a Hash is declared, a Hash sent for an Array group, and type: JSON given a Hash, an Array and garbage. The routing and coercion matrices are unchanged too.

Test plan

  • Full RSpec suite passes locally.
  • RuboCop clean.
  • Mutation-checked: the loop ignoring required? fails 29 specs; ignoring whether the key is present, 6; taking the new path for every scope, 74; stopping at the first error, 2; skipping should_validate? for every scope, 6; always_validated? ignoring optionality, 7; ignoring the parent chain, 1; taking the new path for array scopes, 1 (the new spec); dropping the Hash guard, 2.
  • CI green.

🤖 Generated with Claude Code

@ericproulx
ericproulx force-pushed the perf/root-scope-validation-fast-path branch from e8751c6 to e42e4e8 Compare September 10, 2026 19:53
@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown

Danger Report

No issues found.

View run

Most validators of most endpoints sit on the root `params` scope or a
required Hash scope under it, and for them `Validators::Base` ran the
generic machinery: `should_validate?`, which resolves the scope's params
and walks the parent chain, then an `AttributesIterator` pass that
resolves the params again, checks for arrays and the empty-optional
sentinel and yields once per attribute, then a block that re-checks the
scope's requiredness and dependency. For a root-scope validator that
plumbing took about 570 of its 840 ns; a nested one paid more.

When a scope and every one of its ancestors is required and depends on
nothing, `should_validate?` is true for every request and has no side
effects; `ParamsScope#always_validated?` answers that. If the scope also
does not iterate elements and its params resolve to a Hash, the iterator
path reduces to: for each attribute, `validate_param!(attr, params)` if
it is required or present. `#validate` skips `should_validate?` for such
a scope, and `#validate!` resolves the params once and runs that loop.
Array scopes, `given`, optional scopes and their descendants, and params
that resolve to something other than a Hash keep the existing path.
`DefaultValidator` and the multiple-params validators, which override
`#validate!`, only skip `should_validate?`.

The flag is set in `#initialize`, since validators are frozen once
built. A scope's parent, optionality, dependency and type are fixed
before its block declares anything, so it is final by then.

An Array group handed a Hash reporting only its own type error, without
its members being validated against the Hash, had no spec; routing that
case through the new path passed the whole suite. It now has one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ericproulx
ericproulx force-pushed the perf/root-scope-validation-fast-path branch from e42e4e8 to 358b8a3 Compare September 10, 2026 20:02
@ericproulx ericproulx changed the title Validate root-scope params without the attributes iterator Validate required Hash params scopes without the attributes iterator Sep 10, 2026
ericproulx added a commit that referenced this pull request Sep 11, 2026
…tributes iterator

A validator on `requires :items, type: Array do ... end` went through the
AttributesIterator for every element: an index bookkeeping call, a yield per
attribute, an emptiness test, and per attribute the scope's `required?` and
`meets_dependency?`, plus a `should_validate?` walk up the scope chain before
any of it. When every scope on the chain is required with no dependency and
this is the only one that iterates elements, those answers are fixed: the
scope's params are the request's Array as it came in, and the only thing the
iterator still contributes is the element index the error names carry.

`Validators::Base#validate_elements!` covers that case the way #2927's
`validate_attributes!` covers a Hash scope: it records each index and runs
the same per-attribute check. Nested Array scopes, optional ones and `given`
blocks keep the iterator.

Adds a spec that an optional Array scope skips its empty elements: nothing
pinned that, and letting optional scopes take the new path passed the suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ericproulx added a commit that referenced this pull request Sep 11, 2026
… iterator

A validator on `requires :items, type: Array do ... end` (or `optional`)
went through the AttributesIterator for every element: an index
bookkeeping call, a yield per attribute, an emptiness test, and per
attribute the scope's `required?` and `meets_dependency?`. When the scope
depends on no other param, every scope above it is always validated, and
it is the only one on the chain that iterates elements, those answers are
fixed: its params are the request's Array as it came in, and the iterator
only contributes the element index the error names carry and, for an
optional scope, passing over empty elements.

`Validators::Base#validate_elements!` covers that case the way #2927's
`validate_attributes!` covers a Hash scope. `ParamsScope#validated_when_given?`
names the condition, and `always_validated?` is that plus being required;
a required scope like that also skips `should_validate?`, which always
answers true for it. Nested Array scopes and `given` blocks keep the
iterator.

Adds three specs for behaviour nothing pinned, each found by a mutation
that passed the suite: an optional Array scope skips its empty elements,
it is not validated at all when every element is blank, and a Hash scope
given an Array reports only itself, not the optional Array scope inside.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ericproulx

Copy link
Copy Markdown
Contributor Author

Closing in favour of #2936, which combines #2922#2934 into one PR, re-benchmarked as a whole against master. The write-up here (behaviour matrix, mutation results) still describes this part of the change.

@ericproulx ericproulx closed this Sep 11, 2026
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