Skip to content

Replace caller-depth guard with O(1) re-entrancy detection - #28

Merged
nz merged 2 commits into
mainfrom
fix/drop-caller-guard
Aug 7, 2026
Merged

nz merged 2 commits into
mainfrom
fix/drop-caller-guard

Conversation

@nz

@nz nz commented Aug 7, 2026

Copy link
Copy Markdown
Member

Fixes #16.

Every generated optional reader began with raise "stack too deep" if caller.length > 1024, which materializes the entire backtrace per attribute read. Measured on the gem's readers (100k reads, warm, Ruby 3.4):

Arm shallow stack +100 frames
plain ActiveRecord reader 0.10µs 0.10µs
Option wrapping alone ~0.4µs ~0.4µs
caller guard (as shipped) 3.9µs 42µs
re-entrancy detector (this PR) 0.49µs 0.50µs

This confirms the issue's numbers: the wrapping is cheap, the guard is the entire overhead, and it grows with stack depth.

Why not just delete it (issue's option 1)

The guard existed for a reason: it once caught accidental infinite recursion through the generated readers that was otherwise hard to diagnose. That bug was fixed at some point, but nothing pins it down, so plain deletion would silently drop the diagnostic. This PR keeps the diagnostic and drops the cost — the issue's option 2:

  • In-flight reads tracked in a thread-local keyed by [object_id, attribute].
  • Genuine re-entry raises Errgonomic::RecursiveOptionalReadError (selectively rescuable, unlike the old bare RuntimeError) at the first repeated frame, naming the class and attribute — instead of ~1024 frames in, or a SystemStackError thousands of frames later.
  • A deep-but-legitimate stack no longer trips it: depth is irrelevant, only actual re-entry counts.

Tests

  • Reads succeed at 1100+ frames of legitimate stack depth (previously raised).
  • A model with a layer beneath the reader that re-enters it raises RecursiveOptionalReadError naming the attribute — this is the regression net for the original recursion bug: any recurrence in any form of same-reader re-entry now fails fast and legibly.

Also fixes errgonomic/rails not requiring the gem root, which left the Errgonomic error classes undefined when required directly (as the tests do).

nz added 2 commits August 7, 2026 12:57
Requiring errgonomic/rails directly, as the tests do, previously
skipped lib/errgonomic.rb, so the Errgonomic error classes were
undefined. The root require is cycle-safe: its own conditional rails
require is guarded on Rails::Railtie, which is not yet defined.
The generated optional readers guarded recursion with
caller.length > 1024, which materializes the whole backtrace on every
attribute read: ~4us per read on a shallow stack, ~40us at 100 frames,
versus ~0.4us for the wrapping itself. It also approximated the
hazard: a deep-but-legitimate stack tripped it, while actual re-entry
under 1024 frames did not.

Track in-flight reads in a thread-local keyed by [object_id, attr]
instead, raising RecursiveOptionalReadError at the first repeated
frame. This keeps the diagnostic that motivated the guard (a clear
early error instead of a SystemStackError thousands of frames later)
at ~0.1us constant overhead per read, independent of stack depth.

Fixes #16.
@nz
nz enabled auto-merge August 7, 2026 17:58
Comment on lines +21 to +33
reads = Thread.current[:errgonomic_optional_reads] ||= {}
key = [object_id, :#{name}]
if reads[key]
raise Errgonomic::RecursiveOptionalReadError,
"\#{self.class}##{name} re-entered itself; something beneath this reader reads it again"
end

reads[key] = true
begin
val = super
ensure
reads.delete(key)
end

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Whoa

@nz
nz merged commit bbd993b into main Aug 7, 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.

ActiveRecordOptional: caller.length guard makes every wrapped attribute read ~40x slower

2 participants