Class: Magick::Adapters::ActiveRecord

Inherits:
Base
  • Object
show all
Defined in:
lib/magick/adapters/active_record.rb

Instance Method Summary collapse

Constructor Details

#initialize(model_class: nil) ⇒ ActiveRecord

Returns a new instance of ActiveRecord.



6
7
8
9
10
11
12
13
14
15
16
17
18
# File 'lib/magick/adapters/active_record.rb', line 6

def initialize(model_class: nil)
  @model_class = model_class || default_model_class
  # Cache AR version check once at init time (hot path optimization)
  ar_major = ::ActiveRecord::VERSION::MAJOR
  ar_minor = ::ActiveRecord::VERSION::MINOR
  @use_json = ar_major >= 8 || (ar_major == 7 && ar_minor >= 1)
  # Verify table exists - raise clear error if it doesn't
  unless @model_class.table_exists?
    raise AdapterError, "Table 'magick_features' does not exist. Please run: rails generate magick:active_record && rails db:migrate"
  end
rescue StandardError => e
  raise AdapterError, "Failed to initialize ActiveRecord adapter: #{e.message}"
end

Instance Method Details

#all_features ⇒ Object



81
82
83
84
85
# File 'lib/magick/adapters/active_record.rb', line 81

def all_features
  @model_class.pluck(:feature_name).uniq
rescue StandardError => e
  raise AdapterError, "Failed to get all features from ActiveRecord: #{e.message}"
end

#delete(feature_name) ⇒ Object



57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
# File 'lib/magick/adapters/active_record.rb', line 57

def delete(feature_name)
  feature_name_str = feature_name.to_s
  retries = 5
  begin
    @model_class.where(feature_name: feature_name_str).destroy_all
  rescue ::ActiveRecord::StatementInvalid, ::ActiveRecord::ConnectionTimeoutError => e
    # SQLite busy/locked errors - retry with exponential backoff
    if (e.message.include?('database is locked') || e.message.include?('busy') || e.message.include?('timeout')) && retries > 0
      retries -= 1
      sleep(0.01 * (6 - retries)) # Exponential backoff: 0.01, 0.02, 0.03, 0.04, 0.05
      retry
    end
    raise AdapterError, "Failed to delete from ActiveRecord: #{e.message}"
  rescue StandardError => e
    raise AdapterError, "Failed to delete from ActiveRecord: #{e.message}"
  end
end

#delete_key(feature_name, key) ⇒ Object



157
158
159
160
161
162
163
164
165
166
167
168
# File 'lib/magick/adapters/active_record.rb', line 157

def delete_key(feature_name, key)
  with_retries('delete key') do
    @model_class.transaction do
      record = @model_class.lock.find_by(feature_name: feature_name.to_s)
      data = record&.data || {}
      next false unless data.is_a?(Hash) && data.key?(key.to_s)

      record.update!(data: data.except(key.to_s), updated_at: current_time)
      true
    end
  end
end

#exists?(feature_name) ⇒ Boolean

Returns:

  • (Boolean)


75
76
77
78
79
# File 'lib/magick/adapters/active_record.rb', line 75

def exists?(feature_name)
  @model_class.exists?(feature_name: feature_name.to_s)
rescue StandardError => e
  raise AdapterError, "Failed to check existence in ActiveRecord: #{e.message}"
end

#get(feature_name, key) ⇒ Object



20
21
22
23
24
25
26
27
28
29
30
31
# File 'lib/magick/adapters/active_record.rb', line 20

def get(feature_name, key)
  feature_name_str = feature_name.to_s
  record = @model_class.find_by(feature_name: feature_name_str)
  return nil unless record

  # Handle both Hash (from serialize) and Hash/JSON (from attribute :json)
  data = record.data || {}
  value = data.is_a?(Hash) ? data[key.to_s] : nil
  deserialize_value(value)
rescue StandardError => e
  raise AdapterError, "Failed to get from ActiveRecord: #{e.message}"
end

#get_all_data(feature_name) ⇒ Object



87
88
89
90
91
92
93
94
95
96
97
98
99
# File 'lib/magick/adapters/active_record.rb', line 87

def get_all_data(feature_name)
  record = @model_class.find_by(feature_name: feature_name.to_s)
  return {} unless record

  data = record.data || {}
  return {} unless data.is_a?(Hash)

  data.each_with_object({}) do |(k, v), result|
    result[k.to_s] = deserialize_value(v)
  end
rescue StandardError => e
  raise AdapterError, "Failed to get all data from ActiveRecord: #{e.message}"
end

#load_all_features_data ⇒ Object



101
102
103
104
105
# File 'lib/magick/adapters/active_record.rb', line 101

def load_all_features_data
  collect_features_data(@model_class.all)
rescue StandardError => e
  raise AdapterError, "Failed to load all features from ActiveRecord: #{e.message}"
end

#load_features_data_with_prefix(prefix) ⇒ Object

Prefix filtering happens in SQL so the rows outside the caller's namespace are never read at all — the point being that a boot-time preload must not drag the version archive out of the database. The Ruby-side check is kept as well: LIKE is case-insensitive under some MySQL collations, so the query may match more than the caller asked for (never less, since the pattern is escaped).



113
114
115
116
117
118
119
120
# File 'lib/magick/adapters/active_record.rb', line 113

def load_features_data_with_prefix(prefix)
  prefix = prefix.to_s
  collect_features_data(@model_class.where(*like_prefix_condition(prefix))) do |feature_name|
    feature_name.start_with?(prefix)
  end
rescue StandardError => e
  raise AdapterError, "Failed to load prefixed features from ActiveRecord: #{e.message}"
end

#load_features_data_without_prefixes(prefixes) ⇒ Object



122
123
124
125
126
127
128
129
130
# File 'lib/magick/adapters/active_record.rb', line 122

def load_features_data_without_prefixes(prefixes)
  prefixes = Array(prefixes).map(&:to_s)
  scope = prefixes.reduce(@model_class.all) { |rel, prefix| rel.where.not(*like_prefix_condition(prefix)) }
  collect_features_data(scope) do |feature_name|
    prefixes.none? { |prefix| feature_name.start_with?(prefix) }
  end
rescue StandardError => e
  raise AdapterError, "Failed to load unprefixed features from ActiveRecord: #{e.message}"
end

#next_sequence(feature_name, key, floor: 0) ⇒ Object

Server-side allocation. The read, the bump and the write happen inside one transaction holding the row lock, so two processes sharing the database are serialized and can never be handed the same number. The counter lives in the same row as the archive it numbers, which is why it can never fall behind snapshots that survived a Redis flush.



175
176
177
178
179
180
181
182
183
184
185
186
# File 'lib/magick/adapters/active_record.rb', line 175

def next_sequence(feature_name, key, floor: 0)
  with_retries('allocate sequence') do
    @model_class.transaction do
      record = @model_class.lock.find_or_create_by!(feature_name: feature_name.to_s)
      data = record.data || {}
      data = {} unless data.is_a?(Hash)
      value = [data[key.to_s].to_i, floor.to_i].max + 1
      record.update!(data: data.merge(key.to_s => value), updated_at: current_time)
      value
    end
  end
end

#set(feature_name, key, value) ⇒ Object



33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# File 'lib/magick/adapters/active_record.rb', line 33

def set(feature_name, key, value)
  feature_name_str = feature_name.to_s
  retries = 5
  begin
    @model_class.transaction do
      record = @model_class.lock.find_or_create_by!(feature_name: feature_name_str)
      data = record.data || {}
      data = {} unless data.is_a?(Hash)
      data[key.to_s] = serialize_value(value)
      record.update!(data: data, updated_at: defined?(Time.current) ? Time.current : Time.now)
    end
  rescue ::ActiveRecord::StatementInvalid, ::ActiveRecord::ConnectionTimeoutError => e
    # SQLite busy/locked errors - retry with linear backoff
    if (e.message.include?('database is locked') || e.message.include?('busy') || e.message.include?('timeout')) && retries > 0
      retries -= 1
      sleep(0.01 * (6 - retries))
      retry
    end
    raise AdapterError, "Failed to set in ActiveRecord: #{e.message}"
  rescue StandardError => e
    raise AdapterError, "Failed to set in ActiveRecord: #{e.message}"
  end
end

#set_all_data(feature_name, data_hash) ⇒ Object



132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
# File 'lib/magick/adapters/active_record.rb', line 132

def set_all_data(feature_name, data_hash)
  feature_name_str = feature_name.to_s
  retries = 5
  begin
    @model_class.transaction do
      record = @model_class.lock.find_or_create_by!(feature_name: feature_name_str)
      existing_data = record.data || {}
      existing_data = {} unless existing_data.is_a?(Hash)
      data_hash.each do |key, value|
        existing_data[key.to_s] = serialize_value(value)
      end
      record.update!(data: existing_data, updated_at: defined?(Time.current) ? Time.current : Time.now)
    end
  rescue ::ActiveRecord::StatementInvalid, ::ActiveRecord::ConnectionTimeoutError => e
    if (e.message.include?('database is locked') || e.message.include?('busy') || e.message.include?('timeout')) && retries > 0
      retries -= 1
      sleep(0.01 * (6 - retries))
      retry
    end
    raise AdapterError, "Failed to set all data in ActiveRecord: #{e.message}"
  rescue StandardError => e
    raise AdapterError, "Failed to set all data in ActiveRecord: #{e.message}"
  end
end