Repository navigation
Cache Utils.underscore and Utils.camelize (3x faster from_json on JRuby) - #127
Open
tungleduyxyz wants to merge 2 commits into
Open
tungleduyxyz wants to merge 2 commits into
tungleduyxyz wants to merge 2 commits into
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #125.
Utils.underscore(3 regexgsub!+tr!) andUtils.camelizerun 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:
underscorecall per keyunderscore, flagged by CodeQL, which takes 36.8 s on JRuby for a 50,000-character inputChanges
lib/killbill_client/utils.rbUtils::ConversionCache: thread-safe memoization with lock-free reads of a frozenHashand copy-on-write updates under aMutex. No new runtime dependency.underscoreandcamelizeuse it, with separate caches forcamelize(..., :lower)and:upper. The previous implementations are kept asunderscore_uncached/camelize_uncached, used to fill the caches.Resource.new(hash)built from form params./([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_jsonno longer callsunderscoreagain onnamebefore the setter call, becausenameis already underscored at the top of the loop (andunderscoreis idempotent; there's a spec for that).spec/killbill_client/utils_spec.rb(new, 30 examples):CBA,accountCBA,HTTPResponse,X-Killbill-ApiKey,Foo::BarBaz, already-underscored input,niland symbolscamelizecached separatelyto_hashround trip, including an unknown attribute being ignoredbenchmark/conversions.rb(new, stdlib only): timesunderscore,from_jsonandto_hashon 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 onmaster: this PR only moved it intounderscore_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" * nCRuby'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
underscoreandcamelizenow return frozen, shared strings. Nothing in this gem mutates them, and Kaui doesn't callKillBillClient::Utilsdirectly (I checked killbill-admin-ui). Any external code calling these helpers and mutating the result in place would need todupit first.Benchmark
benchmark/conversions.rb, master (5d66ece) vs this branch, two runs each:from_json, 300 invoicesto_hash, 300 invoicesunderscore, 63k callsJRuby ran in the official
jruby:10.0.6.0-jdk21image (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 (Dockerjruby:10.0.6.0-jdk21) and JRuby 9.4.15.0 (the CI version)Follow-ups (not in this PR)
instantiate_record_from_json(per-class lookup table, norescue nilcontrol flow, cached setter symbols), plus benchmarks on real payloads🤖 Generated with Claude Code