Guard a wrapped read with a flag on the record - #55
Merged
Conversation
The recursion guard kept its bookkeeping in a thread-local hash, which meant building an [object_id, name] key on every wrapped attribute read — an allocation and two hash operations to protect against a case that almost never happens, on the hot path of every converted model. Measured by benchmark:optional_reader, a wrapped read cost 511 ns against 116 ns for the plain reader it replaces, where wrapping without any guard costs 212 ns: the guard was more expensive than the work it guarded. A flag on the record needs no key, so the same protection now costs about 30 ns and one allocation per read is the Option itself. A test pins the allocation count by differencing two batches, so the cost cannot creep back unnoticed, and the benchmark stays in the tree to compare against later. The tradeoff: a record read from two threads at the same time could see the other thread's flag. An ActiveRecord instance shared that way is already outside what ActiveRecord supports. Rubocop is relaxed for test/ and benchmark/ in the same pass: the size cops were pushing tests to be split apart, which hides the behavior each one names.
Actual recursion through a wrapped reader is rare, and the flag the guard uses cannot tell it apart from the same record being read by two threads at once. Whoever hits the second case would be reading a message about the first, so the message names that possibility and where to report it.
allizad
approved these changes
Aug 12, 2026
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.
Fourth of the stack, based on #54.
The recursion guard cost more than the work it guarded
The guard kept its bookkeeping in a thread-local hash, which meant building an
[object_id, name]key on every wrapped attribute read: an allocation and two hash operations to protect against a case that almost never happens, on the hot path of every converted model.Measured with
rake benchmark:optional_reader, which this PR adds, same harness both sides:The guard cost roughly 299 ns against the ~95 ns the wrapping itself costs. A flag on the record needs no key, so it now costs about 30 ns and the only allocation per read is the Option being returned.
This is the same class of problem as #16, an order of magnitude smaller: that guard used
caller.lengthand made a read ~40x slower.What holds it in place
A test differences two batches of reads so the per-read allocation is measured without the harness counting itself: 100 marginal allocations with the flag, 200 with the thread-local hash. The existing recursion cases still hold — a genuine re-entry raises at first re-entry naming the reader, and a legitimately deep stack does not.
benchmark/optional_reader.rbstays in the tree with a rake task, so the next change here can be compared rather than argued about. It reports three shapes: the plain reader as the floor, the wrapped reader, and wrapping with no guard at all as the ceiling on what a guard may cost.The tradeoff, stated plainly
A flag on the record cannot tell genuine recursion apart from the same record instance being read by two threads at once. An ActiveRecord instance shared that way is already outside what ActiveRecord supports, and actual recursion through a wrapped reader is rare — so the error message now names the second possibility and points at the issue tracker, rather than insisting the reader re-entered itself:
Also here
Rubocop stops applying the size cops under
benchmark/, andNaming/PredicatePrefixno longer reads thehas_onemacro override as a predicate. Note that a per-copExcludereplaces the department-levelMetrics: Exclude:, soClassLength,BlockLengthandMethodLengtheach repeat the paths.Testing
rake— 31 tests, 82 assertions, and 118 doctests, all passing. Rubocop reports the same 7 pre-existing offenses asmain.