Class: Parse::NPlusOneDetector

Inherits:
Object
  • Object
show all
Defined in:
lib/parse/query/n_plus_one_detector.rb

Overview

Detects N+1 query patterns when accessing associations.

N+1 queries occur when you load a collection of objects and then access an association on each object individually, triggering a separate query for each. This is inefficient and can be avoided by using includes() to eager-load the associations.

Examples:

Detecting N+1 queries (warn mode - default)

Parse.n_plus_one_mode = :warn

songs = Song.all(limit: 100)
songs.each do |song|
  song.artist.name  # Warning: N+1 query detected on Song.artist
end

Strict mode for CI/tests

Parse.n_plus_one_mode = :raise

songs = Song.all(limit: 100)
songs.each do |song|
  song.artist.name  # Raises Parse::NPlusOneQueryError
end

Avoiding N+1 with includes

songs = Song.all(limit: 100, includes: [:artist])
songs.each do |song|
  song.artist.name  # No warning - artist was eager-loaded
end

Constant Summary collapse

DEFAULT_DETECTION_WINDOW =

Default time window in seconds to track related fetches

2.0
DEFAULT_FETCH_THRESHOLD =

Default minimum number of fetches to trigger a warning

3
DEFAULT_CLEANUP_INTERVAL =

Default cleanup interval in seconds

60.0
TRACKING_KEY =

Thread-local storage key for tracking

:parse_n_plus_one_tracking
CLEANUP_KEY =

Thread-local key for last cleanup time

:parse_n_plus_one_last_cleanup
SOURCE_REGISTRY_KEY =

Thread-local key for source registry (maps object_id to source info)

:parse_n_plus_one_source_registry
VALID_MODES =

Valid modes for N+1 detection

[:warn, :raise, :ignore].freeze
MODE_KEY =

Thread-local key for mode

:parse_n_plus_one_mode

Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

.cleanup_intervalObject



99
100
101
# File 'lib/parse/query/n_plus_one_detector.rb', line 99

def cleanup_interval
  @cleanup_interval || DEFAULT_CLEANUP_INTERVAL
end

.detection_windowObject



91
92
93
# File 'lib/parse/query/n_plus_one_detector.rb', line 91

def detection_window
  @detection_window || DEFAULT_DETECTION_WINDOW
end

.fetch_thresholdObject



95
96
97
# File 'lib/parse/query/n_plus_one_detector.rb', line 95

def fetch_threshold
  @fetch_threshold || DEFAULT_FETCH_THRESHOLD
end

.loggerLogger?

Get the configured logger

Returns:

  • (Logger, nil)


265
266
267
# File 'lib/parse/query/n_plus_one_detector.rb', line 265

def logger
  @logger
end

Class Method Details

.callbacksArray<Proc>

Get registered callbacks

Returns:



255
256
257
# File 'lib/parse/query/n_plus_one_detector.rb', line 255

def callbacks
  @callbacks ||= []
end

.clear_callbacks!Object

Clear all registered callbacks



249
250
251
# File 'lib/parse/query/n_plus_one_detector.rb', line 249

def clear_callbacks!
  @callbacks = []
end

.clear_source_registry!Object

Clear the source registry (called during reset)



131
132
133
# File 'lib/parse/query/n_plus_one_detector.rb', line 131

def clear_source_registry!
  Thread.current[SOURCE_REGISTRY_KEY] = nil
end

.emit_warning(source_class, association, target_class, count) ⇒ Object

Emit an N+1 warning or raise an error based on the current mode.

Parameters:

  • source_class (String)

    the class where the N+1 originated

  • association (Symbol)

    the association causing the N+1

  • target_class (String)

    the class being fetched repeatedly

  • count (Integer)

    the number of fetches detected



211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
# File 'lib/parse/query/n_plus_one_detector.rb', line 211

def emit_warning(source_class, association, target_class, count)
  location = find_user_code_location

  # Call registered callbacks regardless of mode
  callbacks.each { |cb| cb.call(source_class, association, target_class, count, location) }

  case mode
  when :raise
    raise NPlusOneQueryError.new(source_class, association, target_class, count, location)
  when :warn
    message = "[Parse::N+1] Warning: N+1 query detected on #{source_class}.#{association} " \
              "(#{count} separate fetches for #{target_class})"

    if location
      message += "\n  Location: #{location}"
    end

    message += "\n  Suggestion: Use `.includes(:#{association})` to eager-load this association"

    # Output warning
    if logger
      logger.warn(message)
    else
      warn(message)
    end
    # :ignore mode does nothing (but callbacks still run)
  end
end

.enabled=(value) ⇒ Object

Enable or disable N+1 detection for the current thread

Parameters:

  • value (Boolean)

    true enables :warn mode, false sets :ignore mode



161
162
163
# File 'lib/parse/query/n_plus_one_detector.rb', line 161

def enabled=(value)
  self.mode = value ? :warn : :ignore
end

.enabled?Boolean

Whether N+1 detection is enabled (not in ignore mode)

Returns:

  • (Boolean)


155
156
157
# File 'lib/parse/query/n_plus_one_detector.rb', line 155

def enabled?
  mode != :ignore
end

.lookup_source(pointer) ⇒ Hash?

Look up the source info for a pointer object.

Parameters:

Returns:

  • (Hash, nil)

    the source info or nil if not found



124
125
126
127
128
# File 'lib/parse/query/n_plus_one_detector.rb', line 124

def lookup_source(pointer)
  return nil unless pointer
  registry = get_source_registry
  registry[pointer.object_id]
end

.modeSymbol

Get the current N+1 detection mode

Returns:

  • (Symbol)

    :warn, :raise, or :ignore



137
138
139
# File 'lib/parse/query/n_plus_one_detector.rb', line 137

def mode
  Thread.current[MODE_KEY] || :ignore
end

.mode=(value) ⇒ Object

Set the N+1 detection mode

Parameters:

  • value (Symbol)

    :warn, :raise, or :ignore

Raises:

  • (ArgumentError)

    if an invalid mode is provided



144
145
146
147
148
149
150
151
# File 'lib/parse/query/n_plus_one_detector.rb', line 144

def mode=(value)
  value = value.to_sym if value.respond_to?(:to_sym)
  unless VALID_MODES.include?(value)
    raise ArgumentError, "Invalid N+1 mode: #{value.inspect}. Valid modes: #{VALID_MODES.join(", ")}"
  end
  Thread.current[MODE_KEY] = value
  reset! if value == :ignore
end

.on_n_plus_one {|source_class, association, target_class, count, location| ... } ⇒ Object

Register a callback to be called when N+1 is detected. Useful for custom logging or metrics.

Yields:

  • (source_class, association, target_class, count, location)


244
245
246
# File 'lib/parse/query/n_plus_one_detector.rb', line 244

def on_n_plus_one(&block)
  callbacks << block if block_given?
end

.register_source(pointer, source_class:, association:) ⇒ Object

Register a source (class and association) for a pointer object. This uses the object’s Ruby object_id as a key in a thread-local registry, avoiding the need to set instance variables on foreign objects.

Parameters:

  • pointer (Parse::Pointer)

    the pointer object

  • source_class (String)

    the class where the pointer was accessed

  • association (Symbol)

    the association name



110
111
112
113
114
115
116
117
118
# File 'lib/parse/query/n_plus_one_detector.rb', line 110

def register_source(pointer, source_class:, association:)
  return unless pointer && enabled?
  registry = get_source_registry
  registry[pointer.object_id] = {
    source_class: source_class,
    association: association,
    registered_at: Time.now.to_f,
  }
end

.reset!Object

Reset all tracking data



166
167
168
169
# File 'lib/parse/query/n_plus_one_detector.rb', line 166

def reset!
  Thread.current[TRACKING_KEY] = nil
  clear_source_registry!
end

.summaryHash

Get summary statistics of detected N+1 patterns

Returns:

  • (Hash)

    summary of N+1 detections



271
272
273
274
275
276
277
# File 'lib/parse/query/n_plus_one_detector.rb', line 271

def summary
  tracking = get_tracking
  {
    patterns_detected: tracking.count { |_, v| v[:warned] },
    associations: tracking.map { |k, v| { pattern: k, fetches: v[:fetches].size, warned: v[:warned] } },
  }
end

.track_autofetch(source_class:, association:, target_class:, object_id:) ⇒ Object

Track an autofetch event for N+1 detection.

Parameters:

  • source_class (String)

    the class name where the fetch originated

  • association (Symbol)

    the association being accessed

  • target_class (String)

    the class being fetched

  • object_id (String)

    the ID of the object being fetched



177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
# File 'lib/parse/query/n_plus_one_detector.rb', line 177

def track_autofetch(source_class:, association:, target_class:, object_id:)
  return unless enabled?

  tracking = get_tracking
  key = "#{source_class}.#{association}"
  now = Time.now.to_f

  # Periodically clean up stale tracking entries to prevent memory leaks
  # in long-running threads (e.g., Puma, Sidekiq thread pools)
  cleanup_stale_entries(tracking, now)

  # Initialize or update tracking for this association
  tracking[key] ||= { fetches: [], warned: false, target_class: target_class }
  data = tracking[key]

  # Remove stale entries outside the detection window
  data[:fetches] = data[:fetches].select { |t| now - t < detection_window }

  # Add this fetch
  data[:fetches] << now

  # Check if we've exceeded the threshold and haven't warned yet
  if data[:fetches].size >= fetch_threshold && !data[:warned]
    data[:warned] = true
    emit_warning(source_class, association, target_class, data[:fetches].size)
  end
end