Skip to content

fix: memoize field :if/:unless conditions - #608

Merged
lessthanjacob merged 2 commits into
procore-oss:mainfrom
Hamdy:hamdy/fix-memoization
Oct 23, 2026
Merged

lessthanjacob merged 2 commits into
procore-oss:mainfrom
Hamdy:hamdy/fix-memoization

Conversation

@Hamdy

@Hamdy Hamdy commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor

Checklist:

  • I have updated the necessary documentation
  • I have signed off all my commits as required by DCO
  • My build is green

Summary

Each field's :if/:unless lookup was meant to be computed once, but it ran again for every object in every render.

Field#if_callable and #unless_callable cache their result with @ivar ||= callable_from(...). When a field has no condition, which is the common case, callable_from returns false. ||= never caches false, so both lookups ran again for every field of every object, and each one read Blueprinter.configuration.

The fix swaps ||= for a defined? guard, so a false result is cached too. It's two methods in lib/blueprinter/field.rb and adds no allocations. The result is about 40% less time per rendered object.

Benchmarks

I measured main against main plus this fix, using the benchmark suite from #614 (split out of this PR per review). Both ran on the same machine with Ruby 3.3.9, alternating over 5 rounds each. The numbers are medians.

Iterations per second (higher is better)

Benchmark main This PR Change
Basic IPS 43,969 63,276 +43.9%
ActiveRecord IPS 46,684 66,107 +41.6%

Renders per second (higher is better)

Shape main This PR Change
1 object × 6 fields 184,352 272,124 +47.6%
100 objects × 6 fields 2,304 3,786 +64.3%
1,000 objects × 6 fields 233.6 380.3 +62.8%
1,000 objects × 12 fields 118.2 193.6 +63.8%

Microseconds per object (lower is better)

Shape main This PR Change
1 object × 6 fields 5.42 3.67 −32.3%
100 objects × 6 fields 4.34 2.64 −39.2%
1,000 objects × 6 fields 4.28 2.63 −38.6%
1,000 objects × 12 fields 8.46 5.17 −38.9%

Big-O render time, ms (lower is better)

Objects Basic main Basic PR Change ActiveRecord main ActiveRecord PR Change
100 0.276 0.221 −19.9% 0.401 0.295 −26.4%
1,000 2.641 1.943 −26.4% 3.714 2.736 −26.3%
10,000 27.228 20.583 −24.4% 36.258 26.262 −27.6%

Allocations per render (lower is better)

Shape main This PR Change
1 object × 6 fields 10 10 0%
100 objects × 6 fields 108 108 0%
1,000 objects × 6 fields 1,008 1,008 0%
1,000 objects × 12 fields 1,008 1,008 0%

Field#callable_from calls per render (lower is better)

Objects main This PR Change
1 12 12 0%
10 120 12 −90.0%
100 1,200 12 −99.0%
1,000 12,000 12 −99.9%

The last table explains the speedup. The lookup now runs once per field per render (6 fields × :if/:unless = 12) instead of once per field per object.

Behaviour change: please review

If a field has no :if/:unless of its own, it now captures the global config.if/config.unless on its first render. A global condition set after a blueprint has already rendered is ignored, where before it took effect. Setting it before the first render works exactly as before. The snapshots the global condition on first use spec pins this.

Tests

spec/units/field_spec.rb adds coverage for Field#skip?: field-level procs, symbols, global config, and precedence. Two examples fail on main and guard the regression: does not read global configuration again after the first call and snapshots the global condition on first use.

@Hamdy
Hamdy marked this pull request as ready for review September 19, 2026 20:09
@Hamdy
Hamdy requested review from a team and ritikesh as code owners September 19, 2026 20:09
Comment thread CHANGELOG.md Outdated
Comment thread lib/blueprinter/field.rb Outdated
Comment thread spec/benchmarks/allocations_test.rb Outdated
Comment thread spec/units/field_spec.rb Outdated
Comment thread spec/units/field_spec.rb Outdated
Comment thread spec/units/field_spec.rb Outdated
@lessthanjacob

Copy link
Copy Markdown
Contributor

Thanks for pushing this up @Hamdy! The improvements look promising.

I left a few comments, but outside of that, I’d lean towards splitting out the additional benchmarks from the fix itself (given that it represents only a small fraction of the code here). We’ve had previous discussions around reworking the benchmark tooling previously, so I’d be happy to work with you on that separately from this change!

Signed-off-by: hamdy <hamdy.abdelbadeea@procore.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@Hamdy
Hamdy force-pushed the hamdy/fix-memoization branch from 2a24d75 to 885a48c Compare October 23, 2026 09:09
@Hamdy Hamdy mentioned this pull request Oct 23, 2026
2 of 3 tasks
@Hamdy
Hamdy requested a review from lessthanjacob October 23, 2026 12:51
@lessthanjacob
lessthanjacob merged commit 29069ec into procore-oss:main Oct 23, 2026
7 checks passed
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