Skip to content

Cache Utils.underscore and Utils.camelize (3x faster from_json on JRuby) - #127

Open
tungleduyxyz wants to merge 2 commits into
masterfrom
perf/cache-underscore-camelize
Open

tungleduyxyz wants to merge 2 commits into
masterfrom
perf/cache-underscore-camelize

Conversation

@tungleduyxyz

@tungleduyxyz tungleduyxyz commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Closes #125.

Utils.underscore (3 regex gsub! + tr!) and Utils.camelize run for every JSON key of every Kill Bill response (Resource.from_json) and every attribute of every serialized model (Resource#to_hash, and so #== and #hash), and nothing was cached. The inputs are a small, fixed set of attribute names. In JFR profiles of Kaui on JRuby these conversions were the top allocation and CPU sources (details in #125).

This PR:

  • memoizes both conversions
  • removes a redundant second underscore call per key
  • fixes a pre-existing polynomial regex (ReDoS) in underscore, flagged by CodeQL, which takes 36.8 s on JRuby for a 50,000-character input

Changes

  • lib/killbill_client/utils.rb
    • New Utils::ConversionCache: thread-safe memoization with lock-free reads of a frozen Hash and copy-on-write updates under a Mutex. No new runtime dependency.
    • underscore and camelize use it, with separate caches for camelize(..., :lower) and :upper. The previous implementations are kept as underscore_uncached / camelize_uncached, used to fill the caches.
    • Each cache is capped at 10,000 entries. Past that, values are still computed correctly, just not cached. This bounds memory if keys ever come from untrusted input, e.g. Resource.new(hash) built from form params.
    • ReDoS fix: /([A-Z]+)([A-Z][a-z])/ => '\1_\2' is replaced by the equivalent /([A-Z])(?=[A-Z][a-z])/ => '\1_' (details below).
  • lib/killbill_client/models/resource.rb: instantiate_record_from_json no longer calls underscore again on name before the setter call, because name is already underscored at the top of the loop (and underscore is idempotent; there's a spec for that).
  • spec/killbill_client/utils_spec.rb (new, 30 examples):
    • output identical to the previous implementation on real Kill Bill attribute names, including CBA, accountCBA, HTTPResponse, X-Killbill-ApiKey, Foo::BarBaz, already-underscored input, nil and symbols
    • output identical to the original regex on tricky inputs and 2,000 random strings
    • linear time on 50,000 capitals
    • frozen and shared results; not affected by later mutation of the input string
    • upper and lower camelize cached separately
    • the size cap
    • concurrent use from 8 threads
    • a JSON → model → to_hash round trip, including an unknown attribute being ignored
  • benchmark/conversions.rb (new, stdlib only): times underscore, from_json and to_hash on a synthetic list of 300 invoices × 3 items (~1 MB of JSON, roughly a large account). It reports allocations per operation on CRuby. Run it on two checkouts to compare.

ReDoS fix (CodeQL rb/polynomial-redos)

The second regex in underscore, /([A-Z]+)([A-Z][a-z])/, backtracks from every position on a long run of capital letters with no lower case letter. It is already on master: this PR only moved it into underscore_uncached, which is why CodeQL reported it as new here. In Kaui, Resource.new(hash) is built from form params, so input of that shape can reach it, and caching doesn't help on the first call for a given key.

"A" * n old regex, JRuby 10.0.6.0 new regex, JRuby 10.0.6.0
5,000 267 ms 1.8 ms
20,000 5,585 ms 1.9 ms
50,000 36,824 ms 5.1 ms

CRuby's engine happens to handle this pattern (2 ms for 20,000). The replacement inserts _ after a capital that is followed by a capital + lower case letter, which is exactly what the old pattern did (HTTPResponse → HTTP_Response, ABCdEFg → AB_CdE_Fg). Its output is identical on 200,000 random strings (CRuby) and 100,000 (JRuby), and on the edge cases in the specs.

Behaviour change to note

underscore and camelize now return frozen, shared strings. Nothing in this gem mutates them, and Kaui doesn't call KillBillClient::Utils directly (I checked killbill-admin-ui). Any external code calling these helpers and mutating the result in place would need to dup it first.

Benchmark

benchmark/conversions.rb, master (5d66ece) vs this branch, two runs each:

CRuby 3.3.6: master CRuby 3.3.6: branch JRuby 10.0.6.0: master JRuby 10.0.6.0: branch
from_json, 300 invoices 172–206 ms 32–36 ms 142–151 ms 44–46 ms
allocations / invoice 2,200 221 — —
to_hash, 300 invoices 107–115 ms 13–17 ms 40–47 ms 18–22 ms
allocations / invoice 2,170 7 — —
underscore, 63k calls 155–161 ms 10–12 ms 76–106 ms 24–43 ms

JRuby ran in the official jruby:10.0.6.0-jdk21 image (default flags, 40 iterations after 5 warm-up rounds). Absolute numbers depend on the machine; the ratios are what matter. These numbers were measured before the ReDoS fix, which only changes the uncached path (first call per key).

Testing

  • rake test:spec: 84 examples, 0 failures (54 existing + 30 new) on CRuby 3.3.6, JRuby 10.0.6.0 (Docker jruby:10.0.6.0-jdk21) and JRuby 9.4.15.0 (the CI version)
  • CI on the first commit: all 12 test jobs (CRuby 3.2/3.3, JRuby 9.4.15.0 × MySQL/PostgreSQL, including remote specs against Kill Bill) passed; the CodeQL alert is addressed by the second commit

Follow-ups (not in this PR)

🤖 Generated with Claude Code

Both conversions run for every JSON key of every Kill Bill response
(Resource.from_json) and every attribute of every serialized model
(Resource#to_hash, and therefore #== and #hash), on a small, fixed set of
attribute names, and nothing was cached. underscore does three regex
gsub! and a tr! per call; in JFR profiles of Kaui on JRuby these were the
top allocation and CPU sources (see #125).

- Memoize both in a thread-safe ConversionCache: lock-free reads of a
  frozen Hash, copy-on-write updates under a Mutex, capped at 10,000
  entries per cache. No new runtime dependency.
- Cached results are frozen and shared between callers (callers in this
  gem never mutate them).
- instantiate_record_from_json no longer underscores each key a second
  time before calling the setter (the name is already underscored).
- Add specs (output identical to the previous implementation, frozen and
  shared results, cap, concurrent use, JSON round trip) and a stdlib-only
  benchmark (benchmark/conversions.rb).

Benchmark, 300 invoices x 3 items (~1 MB JSON), master vs this change:
  CRuby 3.3.6:  from_json 172-206 -> 32-36 ms, 2200 -> 221 objects/invoice
                to_hash   107-115 -> 13-17 ms, 2170 -> 7 objects/invoice
  JRuby 10.0.6: from_json 142-151 -> 44-46 ms
                to_hash    40-47  -> 18-22 ms

Closes #125

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PYZqhVocqJXxxydmrGkYnV
CodeQL (rb/polynomial-redos) flagged /([A-Z]+)([A-Z][a-z])/ in underscore:
on a string of capitals with no lower case letter, [A-Z]+ backtracks from
every position. On JRuby 10.0.6.0, 50,000 "A"s take 36.8s (5.6s for 20,000);
CRuby's engine happens to handle it. The pattern predates the caching
change, which only moved it into underscore_uncached.

Use /([A-Z])(?=[A-Z][a-z])/ => '\1_' instead: same output (identical on
200,000 random strings and the edge cases in the specs), and 5ms for
50,000 "A"s on JRuby.

Specs: compare with the original implementation on tricky and 2,000
random inputs, and check that 50,000 capitals finish quickly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PYZqhVocqJXxxydmrGkYnV
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.

Performance: cache Utils.underscore / camelize, the top allocation hotspot in Kaui on JRuby

2 participants