Defer pin_tenant processing until class body closes#378
Merged
Conversation
When activated? is true, pin_tenant now registers a one-shot TracePoint that fires after the class body closes, ensuring self.table_name and other declarations are visible. :raise handling disables unconditionally to prevent trace leaks. Co-Authored-By: henkesn <14108170+henkesn@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Test deferred processing via TracePoint (class body closes before
process_pinned_model runs). Test :raise cleanup (trace disabled on
class body failure, no leak). Existing non-activated path unchanged.
Also adds :b_return to TracePoint events in model.rb so Class.new {}
blocks trigger deferred processing (matches real class...end behavior).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
TracePoint(:end) only fires for source-parsed class/module keywords.
Class.new { } blocks fire :b_return instead. Implementation listens
for both to handle Zeitwerk-loaded files and anonymous classes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Mirrors the CampusESP pattern: pin_tenant above self.table_name. Verifies qualification uses the explicit table name (not convention) regardless of declaration order. Adapter-aware assertions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Upgrade guide: no ordering requirement between pin_tenant and self.table_name. CLAUDE.md: TracePoint deferral mechanism with :end/:b_return/:raise, thread safety, reopen edge case. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
:b_return fires for ALL block returns in class context (each, tap,
include hooks), not just Class.new closing — triggers premature
processing before self.table_name is evaluated. Confirmed by MRI
testing and Ruby docs (":b_return: block ending" vs ":end: finish
a class or module definition").
:raise fires for rescued exceptions too (begin/rescue in class body).
Unconditional disable prevents :end from running on successful load.
:end always fires for source-parsed class/module, even on unrescued
raise (MRI verified: event order is [:raise, :end]).
For Class.new { } (tests), :end does not fire. Tests use eval'd
source-parsed classes or call process_pinned_model explicitly.
Extracted apartment_defer_processing! private method from pin_tenant
to stay within Metrics/MethodLength.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…races Multiple TracePoints coexist independently in MRI. Disabling ours does not affect other traces. Both fire for the same :end event. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ntation Design doc: rewrite code sketch, TracePoint section, and raise handling to match :end-only. Remove obsolete :b_return and :raise mitigation sections. Document why each was rejected. model.rb comment: clarify :end fires even on raise (event order [:raise, :end]), add Class.new note. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…l adapter warning - pin_tenant raises ArgumentError if called on non-AR class or module - pin_tenant warns and skips TracePoint for anonymous classes (Class.new) where :end does not fire - Apartment.process_pinned_model warns when adapter is nil instead of silently returning nil via safe navigation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Design doc code sample now shows ArgumentError guard, name.nil? check, and marks sketch as abbreviated - Upgrade guide documents Class.new + constant assignment pattern (call process_pinned_model explicitly) - Unit tests for: ArgumentError on non-AR class, ArgumentError on module, anonymous class warning when activated Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… PR description - CLAUDE.md: add Guards paragraph (ArgumentError on non-AR, anonymous warning) - Design doc: update Testing section to match actual tests (remove stale :raise test, add guard tests) - PR description: final file count, test count, defensive guards section Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…stant spelling - Design doc: soften Class.new + raise wording; note name.nil? guard makes the :b_return leak path unreachable for named classes - Integration test: rename "pin_tenant anywhere" to "via process_pinned_model" (matches what the test actually does) - Spec: TracepointTestModel → TracePointTestModel (match Ruby class name) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This was referenced Apr 10, 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.
Summary
Fixes silent table name qualification bug when
pin_tenantis called beforeself.table_namein a class body under Zeitwerk lazy loading (eager_load = false).pin_tenantnow defersprocess_pinned_modelvia a one-shotTracePoint(:end)constrained toThread.current. Processing runs after the class body's closingendkeyword, soself.table_nameand other declarations are visible regardless of ordering.Problem
Under
eager_load = false+ Zeitwerk,pin_tenantfiredprocess_pinned_modelimmediately. The gem saw no explicit table name, took the convention path, qualified aspublic.engagement_reports. Thenself.table_name = 'reports'silently overwrote the qualification. Queries hit the wrong schema.How it works
TracePoint(:end)is used — fires for source-parsedclass Foo ... end(Zeitwerk files):b_returnrejected: fires for ALL block returns in class context (each,tap,includehooks), not justClass.newclosing — would trigger premature processing:raiserejected: rescued raises (begin/rescuein class body) still produce:end; unconditional disable would prevent processing on successful load. MRI verified::endfires even when the body raises (event order[:raise, :end])target_thread: Thread.currentconstrains to the loading threadprocess_pinned_modelwrapped with begin/rescue/warn/raise for error contextDefensive guards
pin_tenantraisesArgumentErrorif called on a non-AR class or moduleClass.new): warns that:endwon't fire, skips deferral. Useclass MyModel < ApplicationRecordor callprocess_pinned_modelexplicitly after assigning the constantApartment.process_pinned_modelwarns when adapter is nil instead of silently returning nilChanges (8 files, +186 / -28)
lib/apartment/concerns/model.rbapartment_defer_processing!withTracePoint(:end), AR guard, anonymous class warninglib/apartment.rbprocess_pinned_modelnil-adapter warningspec/unit/concerns/model_spec.rbspec/unit/tenant_spec.rbspec/integration/v4/excluded_models_spec.rbdocs/upgrading-to-v4.mdClass.new+ constant assignment gotchalib/apartment/CLAUDE.mddocs/designs/v4-deferred-pin-tenant-processing.mdTest plan
🤖 Generated with Claude Code