Class: Magick::Adapters::ActiveRecord
- Defined in:
- lib/magick/adapters/active_record.rb
Instance Method Summary collapse
- #all_features ⇒ Object
- #delete(feature_name) ⇒ Object
- #delete_key(feature_name, key) ⇒ Object
- #exists?(feature_name) ⇒ Boolean
- #get(feature_name, key) ⇒ Object
- #get_all_data(feature_name) ⇒ Object
-
#initialize(model_class: nil) ⇒ ActiveRecord
constructor
A new instance of ActiveRecord.
- #load_all_features_data ⇒ Object
-
#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.
- #load_features_data_without_prefixes(prefixes) ⇒ Object
-
#next_sequence(feature_name, key, floor: 0) ⇒ Object
Server-side allocation.
- #set(feature_name, key, value) ⇒ Object
- #set_all_data(feature_name, data_hash) ⇒ Object
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..include?('database is locked') || e..include?('busy') || e..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
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..include?('database is locked') || e..include?('busy') || e..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..include?('database is locked') || e..include?('busy') || e..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 |