Skip to content

perf: cache Frame objects in StacktraceBuilder by Line identity#2906

Open
HazAT wants to merge 1 commit intomasterfrom
perf/frame-object-caching
Open

perf: cache Frame objects in StacktraceBuilder by Line identity#2906
HazAT wants to merge 1 commit intomasterfrom
perf/frame-object-caching

Conversation

@HazAT
Copy link
Member

@HazAT HazAT commented Mar 18, 2026

⚠️ Needs closer review — caches Frame objects by Line object_id

Part of #2901 (reduce memory allocations by ~53%)

Changes

Cache Frame objects in StacktraceBuilder#convert_parsed_line_into_frame keyed by Line#object_id. When combined with Line object caching (#2905), the same cached Line instances produce the same Frame instances, eliminating ~72 duplicate Frame creations per exception.

  • Cache is per-StacktraceBuilder instance (not class-level)
  • Bounded to 2048 entries
  • Frame objects are effectively read-only after context is set — attributes are only read during to_h serialization

Review focus

  • Keying by object_id — works correctly when Line objects are cached (same object = same id). For non-cached Lines, unique ids mean no false cache hits. Is this assumption documented clearly enough?
  • Most effective when paired with perf: cache backtrace line parsing and Line object creation #2905 (Line caching), but provides modest benefit independently within a single exception's backtrace.

⚠️ Needs closer review — caches Frame objects by Line object_id.

When combined with Line object caching (where identical backtrace lines
return the same Line instance), this cache avoids recreating Frame
objects for the same source locations across exceptions.

The cache key is Line#object_id, which is stable for cached Line objects.
For non-cached Lines, unique object_ids mean no false cache hits.

Cache is per-StacktraceBuilder instance (not class-level), bounded to
2048 entries. Frame objects are effectively read-only after context is
set — attributes are only read during to_h serialization.

This is most effective when paired with the Line object caching PR,
but provides modest benefit independently (avoids Frame recreation
within a single exception's backtrace when duplicate frames exist).
HazAT added a commit that referenced this pull request Mar 18, 2026
Reduce total allocated memory from 442k to 206k bytes (-53.5%) and
objects from 3305 to 1538 (-53.5%) per Rails exception capture.

All changes are internal optimizations with zero behavior changes.

Key optimizations:
- Cache longest_load_path and compute_filename results (class-level,
  invalidated on $LOAD_PATH changes)
- Cache backtrace line parsing and Line/Frame object creation (bounded
  at 2048 entries)
- Optimize LineCache with Hash#fetch, direct context setting, and
  per-(filename, lineno) caching
- Avoid unnecessary allocations: indexed regex captures, match? instead
  of =~, byteslice, single-pass iteration in StacktraceBuilder
- RequestInterface: avoid env.dup, cache header name transforms, ASCII
  fast-path for encoding
- Scope/BreadcrumbBuffer: shallow dup instead of deep_dup where inner
  values are not mutated after duplication
- Hub#add_breadcrumb: hint default nil instead of {} to avoid empty
  hash allocation

See sub-PRs for detailed review by risk level:
- #2902 (low risk) — hot path allocation avoidance
- #2903 (low risk) — LineCache optimization
- #2904 (medium risk) — load path and filename caching
- #2905 (needs review) — backtrace parse caching
- #2906 (needs review) — Frame object caching
- #2907 (needs review) — Scope/BreadcrumbBuffer shallow dup
- #2908 (medium risk) — RequestInterface optimizations

def convert_parsed_line_into_frame(line)
# Cache frames by Line object identity — same Line produces same Frame
cache_key = line.object_id
Copy link

Choose a reason for hiding this comment

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

Bug: The frame cache uses line.object_id as a key, which is unsafe as object_ids can be reused after garbage collection, leading to incorrect cache hits.
Severity: HIGH

Suggested Fix

Use a cache key that uniquely and reliably identifies the source line, such as a string combining file, lineno, and method. This avoids the problem of object_id reuse. Additionally, consider cloning Frame objects when returning them from the cache to prevent mutations in callbacks from affecting the cached instance.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: sentry-ruby/lib/sentry/interfaces/stacktrace_builder.rb#L82

Potential issue: The frame caching mechanism uses `line.object_id` as a cache key. Since
Ruby can reuse `object_id`s for new objects after the original object has been garbage
collected, this can lead to cache key collisions. A `Frame` object for one source code
location could be incorrectly returned for a completely different location if a new
`Line` object happens to be assigned a recycled `object_id`. This would result in
corrupted stack trace data, where frames from one exception's backtrace appear in
another's.

Did we get this right? 👍 / 👎 to inform future reviews.

Comment on lines +83 to +84
cached_frame = @frame_cache&.[](cache_key)
return cached_frame if cached_frame
Copy link

Choose a reason for hiding this comment

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

Bug: Cached Frame objects are mutable and can be modified by callbacks. These mutations persist in the cache, causing data to leak between different events.
Severity: MEDIUM

Suggested Fix

Clone the Frame object before returning it from the cache. This ensures that any mutations made by callbacks affect only a copy of the object, leaving the cached version pristine for subsequent use. For example, change return cached_frame to return cached_frame.dup.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: sentry-ruby/lib/sentry/interfaces/stacktrace_builder.rb#L83-L84

Potential issue: The `StacktraceBuilder` caches and reuses `Frame` object instances.
These objects are passed to a user-configurable `frame_callback`, which can mutate them
(e.g., by adding `vars`). Because the cache stores and returns a reference to the same
mutable object, any modifications made by the callback persist in the cache. Subsequent
exceptions that hit the same cache entry will receive the already-mutated `Frame`
object, leading to data from one event leaking into another.

Did we get this right? 👍 / 👎 to inform future reviews.

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

frame.set_context(linecache, context_lines) if context_lines

@frame_cache ||= {}
@frame_cache[cache_key] = frame if @frame_cache.size < 2048
Copy link

Choose a reason for hiding this comment

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

Cached frames mutated after caching cause data leakage

High Severity

StacktraceBuilder is a memoized singleton on Configuration, so @frame_cache persists across all events for the application lifetime. Cached Frame objects are mutated after caching — SingleExceptionInterface.build_with_stacktrace sets stacktrace.frames.last&.vars = locals, and frame_callback can also modify frames in-place (as demonstrated in the existing test). These mutations corrupt the cached Frame, causing exception locals or callback modifications from one error event to bleed into future events that reuse the same cached frame.

Fix in Cursor Fix in Web

frame.set_context(linecache, context_lines) if context_lines

@frame_cache ||= {}
@frame_cache[cache_key] = frame if @frame_cache.size < 2048
Copy link

Choose a reason for hiding this comment

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

Cache keyed by object_id breaks after GC

Medium Severity

The cache keys by line.object_id but stores only the integer key and Frame value — it holds no reference to the Line object itself. Since StacktraceBuilder is a long-lived singleton, original Line objects can be garbage collected and their object_id reassigned to completely different Line objects. This produces false cache hits, returning an incorrect Frame for a different backtrace line.

Fix in Cursor Fix in Web

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.

1 participant