Skip to content

Guard a wrapped read with a flag on the record - #55

Merged
nz merged 2 commits into
nz/nested-attributes-unwrapfrom
nz/cheap-recursion-guard
Aug 12, 2026
Merged

Guard a wrapped read with a flag on the record#55
nz merged 2 commits into
nz/nested-attributes-unwrapfrom
nz/cheap-recursion-guard

Conversation

@nz

@nz nz commented Aug 12, 2026

Copy link
Copy Markdown
Member

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:

shape before after
plain reader 116 ns, 0 allocs 112 ns, 0 allocs
wrapped 511 ns (4.4x), 2 allocs 232 ns (2.1x), 1 alloc
wrapping with no guard at all 212 ns (1.8x), 1 alloc 204 ns (1.9x), 1 alloc

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.length and 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.rb stays 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:

LoopyAuthor#bio re-entered itself; something beneath this reader reads it again.
If this read was not recursive, the record may have been read from two threads at once,
which this guard cannot tell apart. Please report that at
https://github.com/omc/errgonomic/issues

Also here

Rubocop stops applying the size cops under benchmark/, and Naming/PredicatePrefix no longer reads the has_one macro override as a predicate. Note that a per-cop Exclude replaces the department-level Metrics: Exclude:, so ClassLength, BlockLength and MethodLength each repeat the paths.

Testing

rake — 31 tests, 82 assertions, and 118 doctests, all passing. Rubocop reports the same 7 pre-existing offenses as main.

nz added 2 commits August 12, 2026 15:12
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.
@nz
nz merged commit ec0bffe into nz/nested-attributes-unwrap Aug 12, 2026
1 check 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