Skip to content

[BAC-1484] Speed up Entity.represent for large nested payloads (~3-4x) - #26

Open
taleh007 wants to merge 2 commits into
masterfrom
feature/BAC-1484
Open

[BAC-1484] Speed up Entity.represent for large nested payloads (~3-4x)#26
taleh007 wants to merge 2 commits into
masterfrom
feature/BAC-1484

Conversation

@taleh007

@taleh007 taleh007 commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

What

Entity.represent was slow on large payloads. This PR makes it ~4x faster for hash sources and ~3x faster for entity object graphs, and adds an opt-in performance spec suite that documents the baseline.

Benchmark — one catalog entity holding 5000 products, each with regular fields plus a small nested entity (min / avg over 5 runs, same machine):

Source shape Before After Speedup
Hash payload 712 / 731 ms 168 / 180 ms 4.2x
Entity object graph 601 / 623 ms 211 / 248 ms 2.8x

Why it works (in simple terms)

Representing a list of 5000 products means calling represent 10,001 times (the root + each product + each product's nested entity). The old code redid the same preparation inside every one of those calls — preparation whose result never changes:

  1. It kept re-translating the same column names. Every product has the same attributes, and created_at camelizes to createdAt every single time — yet the inflector ran again for every attribute of every object (~50,000 times per call). Now each class computes its "represent plan" once — the output key, the type and the custom serializer for each attribute — and every later call just walks that precomputed list. Like a spreadsheet: you translate the header row once, not once per row. (Same trick as the compiled JSON assigner from BAC-1267 — and like it, the plan is built per exact class and invalidated if attribute / serializes is called later.)

  2. It kept photocopying hashes just to read them. Every nested hash was wrapped in with_indifferent_access — a full copy — only so fields could be found whether the keys are strings or symbols. Now we simply try the string key first and the symbol key second; no copy at all. The one place that genuinely relies on the copy — serializes blocks, which receive the source hash and may look fields up either way — still gets it, so their behavior is unchanged.

  3. It allocated option hashes nobody asked for. default_represent_options.merge(options) ran for every nested entity even when options was empty. The merge is now skipped when there is nothing to merge.

None of this changes what represent returns — it just stops paying per object for work that is per class. One observable nuance: untyped hash attributes now pass the original hash through instead of an indifferent-access copy (identical once rendered to JSON).

Performance specs

New opt-in suite, excluded from the default run (CI is unaffected):

PERFORMANCE=1 bundle exec rspec spec/performance

It represents the 5000-product catalog from both source shapes, asserts the output is correct, prints the timings, and fails only if a run exceeds a deliberately generous 2s budget — a catastrophic-regression guard (e.g. accidentally quadratic serialization), not a microbenchmark.

Jira

BAC-1484

🤖 Generated with Claude Code

Precompute [json_name, name, type, custom_serializer] tuples once per
class instead of camelizing names and looking up serializers on every
represent call. Read hash fields via string-then-symbol lookup instead
of copying each hash with_indifferent_access (kept for classes with
custom serializers, whose blocks receive the hash), and skip the
options merge when no options are passed.
@taleh007
taleh007 requested review from HolyWalley and yard August 7, 2026 11:30
@taleh007
taleh007 marked this pull request as ready for review August 7, 2026 11:30
Comment thread spec/spec_helper.rb
config.example_status_persistence_file_path = ".rspec_status"

# Performance specs are opt-in: PERFORMANCE=1 bundle exec rspec spec/performance
config.filter_run_excluding :performance unless ENV["PERFORMANCE"]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it take much to run? maybe make them on by default?

@yard yard left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the dynamically emitted serialization/deserialization code still apply? Any optimizations to be made there?

if object_or_hash.is_a?(Hash)
object_or_hash[name]
value = object_or_hash[name]
value.nil? ? object_or_hash[name.to_sym] : value

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we are talking optimizations here, shall this “try this, then try that” be gone too?

@yard yard left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also pt. 2 (killing with_indifferent_access everywhere but for custom serilizer block) does look a tad weird, we probably don’t really gain much from it but now implement it ourselves

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.

3 participants