Module: RaceGuard::DBLockAuditor::ReadModifyWrite::ReadModWriteImpl

Defined in:
lib/race_guard/db_lock_auditor/read_modify_write.rb

Overview

Internal: keeps Patches and singleton API small (RuboCop). rubocop:disable Metrics/ModuleLength – one cohesive implementation unit for 4.1

Constant Summary collapse

WRITE_DEPTH_KEY =
:__race_guard_rmw_in_save_depth

Class Method Summary collapse

Class Method Details

.after_relation_update_all(payload) ⇒ Object



234
235
236
237
238
239
# File 'lib/race_guard/db_lock_auditor/read_modify_write.rb', line 234

def after_relation_update_all(payload)
  return unless payload

  forget_reads_for_relation_records!(payload[:model_class], payload[:ids])
  nil
end

.after_save(record, success, write_label) ⇒ Object



155
156
157
158
159
# File 'lib/race_guard/db_lock_auditor/read_modify_write.rb', line 155

def after_save(record, success, write_label)
  return unless success

  check_after_persisted_save!(record, write_label)
end

.around_with_lock_user_block(record) ⇒ Object



198
199
200
201
202
203
204
205
206
# File 'lib/race_guard/db_lock_auditor/read_modify_write.rb', line 198

def around_with_lock_user_block(record)
  pk = primary_key_value_for_rmw(record)
  begin
    RaceGuard.context.rmw_with_lock_block_enter!(record.class, pk) if pk
    yield
  ensure
    RaceGuard.context.rmw_with_lock_block_leave!(record.class, pk) if pk
  end
end

.atomic_sql_update_all?(updates) ⇒ Boolean

Returns:

  • (Boolean)


241
242
243
244
245
246
# File 'lib/race_guard/db_lock_auditor/read_modify_write.rb', line 241

def atomic_sql_update_all?(updates)
  return true if updates.is_a?(String)
  return true if defined?(Arel::Nodes::SqlLiteral) && updates.is_a?(Arel::Nodes::SqlLiteral)

  false
end

.before_relation_update_all(relation, updates) ⇒ Object



222
223
224
225
226
227
228
229
230
231
232
# File 'lib/race_guard/db_lock_auditor/read_modify_write.rb', line 222

def before_relation_update_all(relation, updates)
  return nil unless atomic_sql_update_all?(updates)
  return nil unless should_track_write_class?(relation.klass)

  ids = relation_target_record_ids(relation)
  return nil if ids.empty?

  { model_class: relation.klass, ids: ids }
rescue StandardError
  nil
end

.capture_read!(record, attr) ⇒ Object



145
146
147
148
149
150
151
152
153
# File 'lib/race_guard/db_lock_auditor/read_modify_write.rb', line 145

def capture_read!(record, attr)
  return if inside_persisted_write?
  return unless should_track_read?(record)

  pk = primary_key_value_for_rmw(record)
  return unless pk

  RaceGuard.context.rmw_read_record!(record.class, pk, attr)
end

.check_after_persisted_save!(record, write_label) ⇒ Object



161
162
163
164
165
166
167
168
169
170
171
172
# File 'lib/race_guard/db_lock_auditor/read_modify_write.rb', line 161

def check_after_persisted_save!(record, write_label)
  return unless should_track_write?(record)
  return unless record.persisted?

  pk = primary_key_value_for_rmw(record)
  return if pk.nil?

  changes = record.saved_changes
  return if changes.nil? || changes.empty?

  each_rmw_matching_change(record, changes, write_label, pk)
end

.each_rmw_matching_change(record, changes, write_label, record_pk) ⇒ Object



174
175
176
177
178
179
180
181
182
183
184
185
186
187
# File 'lib/race_guard/db_lock_auditor/read_modify_write.rb', line 174

def each_rmw_matching_change(record, changes, write_label, record_pk)
  changes.each_key do |attr_name|
    age_ms = RaceGuard.context.rmw_read_age_ms_for(record.class, record_pk, attr_name)
    next unless age_ms

    if skip_rmw_due_to_pessimistic_lock?(record, record_pk)
      RaceGuard.context.rmw_read_forget!(record.class, record_pk, attr_name)
      next
    end

    report_read_modify_write!(record, attr_name, age_ms, write_label, record_pk)
    RaceGuard.context.rmw_read_forget!(record.class, record_pk, attr_name)
  end
end

.enter_persisted_write!Object



91
92
93
94
# File 'lib/race_guard/db_lock_auditor/read_modify_write.rb', line 91

def enter_persisted_write!
  d = Thread.current[WRITE_DEPTH_KEY].to_i + 1
  Thread.current[WRITE_DEPTH_KEY] = d
end

.forget_reads_for_relation_records!(model_class, ids) ⇒ Object



258
259
260
261
262
263
# File 'lib/race_guard/db_lock_auditor/read_modify_write.rb', line 258

def forget_reads_for_relation_records!(model_class, ids)
  ids.each do |id|
    RaceGuard.context.rmw_read_forget_record!(model_class, id)
  end
  nil
end

.inside_persisted_write?Boolean

Returns:

  • (Boolean)


101
102
103
# File 'lib/race_guard/db_lock_auditor/read_modify_write.rb', line 101

def inside_persisted_write?
  Thread.current[WRITE_DEPTH_KEY].to_i.positive?
end

.leave_persisted_write!Object



96
97
98
99
# File 'lib/race_guard/db_lock_auditor/read_modify_write.rb', line 96

def leave_persisted_write!
  d = Thread.current[WRITE_DEPTH_KEY].to_i - 1
  Thread.current[WRITE_DEPTH_KEY] = d <= 0 ? nil : d
end

.normalize_attr_name_for_read(record, attr) ⇒ Object



119
120
121
122
123
124
# File 'lib/race_guard/db_lock_auditor/read_modify_write.rb', line 119

def normalize_attr_name_for_read(record, attr)
  s = attr.to_s
  return s unless record.class.respond_to?(:attribute_aliases)

  record.class.attribute_aliases[s] || s
end

.note_read_from__read_attribute(record, raw_attr, _val) ⇒ Object



126
127
128
129
130
131
132
133
# File 'lib/race_guard/db_lock_auditor/read_modify_write.rb', line 126

def note_read_from__read_attribute(record, raw_attr, _val)
  return if raw_attr.nil? || inside_persisted_write?

  n = normalize_attr_name_for_read(record, raw_attr)
  capture_read!(record, n)
rescue StandardError
  nil
end

.note_read_from_read_attribute(record, raw_attr, _val) ⇒ Object



135
136
137
138
139
140
141
142
143
# File 'lib/race_guard/db_lock_auditor/read_modify_write.rb', line 135

def note_read_from_read_attribute(record, raw_attr, _val)
  return if raw_attr.nil? || inside_persisted_write?

  n = raw_attr.to_s
  n = record.class.attribute_aliases[n] || n if record.class.respond_to?(:attribute_aliases)
  capture_read!(record, n)
rescue StandardError
  nil
end

.primary_key_value_for_rmw(record) ⇒ Object



105
106
107
108
109
110
111
112
113
114
115
116
117
# File 'lib/race_guard/db_lock_auditor/read_modify_write.rb', line 105

def primary_key_value_for_rmw(record)
  return nil unless record.is_a?(::ActiveRecord::Base)
  return nil if record.new_record?

  pk = record.class.primary_key
  name = (pk.is_a?(Array) ? pk.first : pk).to_s
  set = record.instance_variable_get(:@attributes)
  return nil unless set

  set.fetch_value(name)
rescue StandardError
  nil
end

.record_pessimistic_lock_for_record!(record) ⇒ Object



208
209
210
211
212
213
214
215
216
217
218
219
220
# File 'lib/race_guard/db_lock_auditor/read_modify_write.rb', line 208

def record_pessimistic_lock_for_record!(record)
  return unless should_track_write?(record)
  return unless record.is_a?(::ActiveRecord::Base)
  return if record.new_record?

  pk = primary_key_value_for_rmw(record)
  return if pk.nil?

  RaceGuard.context.rmw_pessimistic_lock_register!(record.class, pk)
  RaceGuard.context.rmw_read_forget_record!(record.class, pk)
rescue StandardError
  nil
end

.relation_target_record_ids(relation) ⇒ Object



248
249
250
251
252
253
254
255
256
# File 'lib/race_guard/db_lock_auditor/read_modify_write.rb', line 248

def relation_target_record_ids(relation)
  klass = relation.klass
  pk = klass.primary_key
  return [] if pk.nil? || pk.is_a?(Array)

  relation.except(:select).pluck(pk).compact.uniq
rescue StandardError
  []
end

.report_read_modify_write!(record, attr_name, read_age_ms, write_label, record_pk = nil) ⇒ Object

rubocop:disable Metrics/MethodLength – one linear report; keeps context fields explicit



294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
# File 'lib/race_guard/db_lock_auditor/read_modify_write.rb', line 294

def report_read_modify_write!(record, attr_name, read_age_ms, write_label, record_pk = nil)
  cfg = RaceGuard.configuration
  msg = "Read-modify-write on #{record.class.name}##{attr_name} " \
        '(read then persisted save; consider atomic SQL, locking, or idempotency)'
  snapshot = RaceGuard.context.current
  sev = cfg.severity_for(:'db_lock_auditor:read_modify_write')
  rid = record_pk || primary_key_value_for_rmw(record)
  ctx = {
    'model' => record.class.name,
    'record_id' => rid,
    'attribute' => attr_name.to_s,
    'write_method' => write_label,
    'in_transaction' => snapshot.in_transaction?,
    'read_age_ms' => read_age_ms.round
  }
  RaceGuard.report(
    detector: ReadModifyWrite::DETECTOR,
    message: msg,
    severity: sev,
    context: ctx
  )
end

.should_track_read?(record) ⇒ Boolean

Returns:

  • (Boolean)


265
266
267
268
269
270
271
272
273
274
# File 'lib/race_guard/db_lock_auditor/read_modify_write.rb', line 265

def should_track_read?(record)
  cfg = RaceGuard.configuration
  return false unless cfg.active?
  return false unless record.is_a?(::ActiveRecord::Base)
  return false if record.new_record?
  return false if primary_key_value_for_rmw(record).nil?
  return false if cfg.db_lock_read_modify_write_models.empty?

  cfg.db_lock_read_modify_write_tracks?(record.class)
end

.should_track_write?(record) ⇒ Boolean

Returns:

  • (Boolean)


276
277
278
279
280
281
282
# File 'lib/race_guard/db_lock_auditor/read_modify_write.rb', line 276

def should_track_write?(record)
  cfg = RaceGuard.configuration
  return false unless cfg.active?
  return false if cfg.db_lock_read_modify_write_models.empty?

  cfg.db_lock_read_modify_write_tracks?(record.class)
end

.should_track_write_class?(klass) ⇒ Boolean

Returns:

  • (Boolean)


284
285
286
287
288
289
290
291
# File 'lib/race_guard/db_lock_auditor/read_modify_write.rb', line 284

def should_track_write_class?(klass)
  cfg = RaceGuard.configuration
  return false unless cfg.active?
  return false unless klass.is_a?(Class)
  return false if cfg.db_lock_read_modify_write_models.empty?

  cfg.db_lock_read_modify_write_tracks?(klass)
end

.skip_rmw_due_to_pessimistic_lock?(record, record_pk) ⇒ Boolean

Returns:

  • (Boolean)


189
190
191
192
193
194
195
196
# File 'lib/race_guard/db_lock_auditor/read_modify_write.rb', line 189

def skip_rmw_due_to_pessimistic_lock?(record, record_pk)
  return true if RaceGuard.context.rmw_pessimistic_lock_active?(record.class, record_pk)

  depth = RaceGuard.context.rmw_with_lock_block_depth_for(record.class, record_pk)
  return true if depth.positive?

  false
end