rollout-active_record-adapter 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ecefa7e74cc8b39f614ae2e2cb1a33e7072aa13ded266d394052356c41139492
4
+ data.tar.gz: 1e2cccffc216f98f7a10507fc90d9b9f27612135e878919d1296ace3f35e859b
5
+ SHA512:
6
+ metadata.gz: abf00af86c57f1340d03ef90d33c4225b6f82a183aee526c35c5f6f6e4ed479597579fe7e5c74ab06e793ab65d2ca00d234c30125b26c5eedef1707ff28682f5
7
+ data.tar.gz: 5c597704919dfdd503bfdcbd9a28175cc94ab49454d49045ead229e18315c6f48493a4f66e8d27511fbcd320c801a418b8caf9b0aec3f9369310b49af272785c
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2010-InfinityAndBeyond BitLove, Inc.
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ class Rollout
7
+ module ActiveRecord
8
+ class InstallGenerator < ::Rails::Generators::Base
9
+ include ::ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ class_option :database, type: :string, desc: "Database key to generate the migration for"
14
+
15
+ def self.next_migration_number(dirname)
16
+ ::ActiveRecord::Generators::Base.next_migration_number(dirname)
17
+ end
18
+
19
+ def copy_migration
20
+ migration_template(
21
+ "create_rollout_tables.rb.tt",
22
+ File.join(db_migrate_path, "create_rollout_tables.rb"),
23
+ )
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateRolloutTables < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
4
+ def change
5
+ name_options = { null: false }
6
+ name_options[:collation] = "utf8mb4_bin" if connection.adapter_name.match?(/mysql/i)
7
+ payload_options = { null: false }
8
+ payload_options[:limit] = 16_777_215 if connection.adapter_name.match?(/mysql/i)
9
+
10
+ create_table :rollout_features do |t|
11
+ t.string :name, **name_options
12
+ t.float :percentage, limit: 53, null: false, default: 0.0
13
+ t.text :users, **payload_options
14
+ t.text :groups, **payload_options
15
+ t.text :data, **payload_options
16
+ t.timestamps
17
+ end
18
+
19
+ add_index :rollout_features, :name, unique: true
20
+
21
+ create_table :rollout_events do |t|
22
+ t.string :feature_name, **name_options
23
+ t.string :event_name, null: false
24
+ t.text :data, **payload_options
25
+ t.text :context, **payload_options
26
+ t.boolean :feature_visible, null: false, default: true
27
+ t.boolean :global_visible, null: false, default: true
28
+ t.datetime :occurred_at, null: false, precision: 6
29
+ end
30
+
31
+ add_index :rollout_events,
32
+ [:feature_name, :feature_visible, :occurred_at],
33
+ name: "index_rollout_events_for_feature_history"
34
+ add_index :rollout_events,
35
+ [:global_visible, :occurred_at],
36
+ name: "index_rollout_events_for_global_history"
37
+ add_index :rollout_events,
38
+ [:feature_visible, :global_visible],
39
+ name: "index_rollout_events_for_hidden_cleanup"
40
+ end
41
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'rollout/feature_state'
5
+ require 'rollout/logging'
6
+
7
+ class Rollout
8
+ module ActiveRecord
9
+ class Codec
10
+ def self.dump(value)
11
+ JSON.generate(value)
12
+ end
13
+
14
+ def self.load_array(payload)
15
+ JSON.parse(payload)
16
+ end
17
+
18
+ def self.load_hash(payload)
19
+ JSON.parse(payload)
20
+ end
21
+
22
+ def self.load_event_hash(payload)
23
+ JSON.parse(payload, symbolize_names: true)
24
+ end
25
+
26
+ def self.feature_state(name, record)
27
+ if record.nil?
28
+ return FeatureState.new(
29
+ name: name,
30
+ percentage: 0.0,
31
+ users: [],
32
+ groups: [],
33
+ data: {},
34
+ )
35
+ end
36
+
37
+ FeatureState.new(
38
+ name: name,
39
+ percentage: record.percentage,
40
+ users: load_array(record.users),
41
+ groups: load_array(record.groups),
42
+ data: load_hash(record.data),
43
+ )
44
+ end
45
+
46
+ def self.event(record)
47
+ Logging::Event.new(
48
+ feature: record.feature_name,
49
+ name: record.event_name,
50
+ data: load_event_hash(record.data),
51
+ context: load_event_hash(record.context),
52
+ created_at: record.occurred_at,
53
+ )
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Rollout
4
+ module ActiveRecord
5
+ class FeatureCache
6
+ DEFAULT_MAX_SIZE = 4096
7
+
8
+ def initialize(ttl_seconds:, clock: nil, max_size: DEFAULT_MAX_SIZE)
9
+ unless ttl_seconds.is_a?(Integer) && ttl_seconds > 0
10
+ raise ArgumentError, "cache_ttl_seconds must be an Integer > 0"
11
+ end
12
+ unless max_size.is_a?(Integer) && max_size > 0
13
+ raise ArgumentError, "max_size must be an Integer > 0"
14
+ end
15
+
16
+ @ttl_seconds = ttl_seconds
17
+ @max_size = max_size
18
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
19
+ @entries = {}
20
+ @generation = 0
21
+ @mutex = Mutex.new
22
+ end
23
+
24
+ def generation
25
+ @mutex.synchronize { @generation }
26
+ end
27
+
28
+ def read(name, context: nil)
29
+ key = entry_key(name, context)
30
+ @mutex.synchronize do
31
+ entry = @entries[key]
32
+ return nil unless entry
33
+ if entry[:expires_at] <= now
34
+ @entries.delete(key)
35
+ return nil
36
+ end
37
+
38
+ entry[:state].deep_clone
39
+ end
40
+ end
41
+
42
+ def fill(generation, states, context: nil)
43
+ clones = states.map { |state| [state.name.to_s, state.deep_clone] }
44
+ @mutex.synchronize do
45
+ return if generation != @generation
46
+
47
+ clones.each do |name, clone|
48
+ store_locked(name, clone, context)
49
+ end
50
+ end
51
+ end
52
+
53
+ def delete(*names, context: nil)
54
+ keys = names.map { |name| entry_key(name, context) }
55
+ @mutex.synchronize do
56
+ @generation += 1
57
+ keys.each { |key| @entries.delete(key) }
58
+ end
59
+ end
60
+
61
+ def clear
62
+ @mutex.synchronize do
63
+ @generation += 1
64
+ @entries.clear
65
+ end
66
+ end
67
+
68
+ private
69
+
70
+ def store_locked(name, clone, context)
71
+ key = entry_key(name, context)
72
+ @entries.delete(key)
73
+ if @entries.size >= @max_size
74
+ prune_expired_locked
75
+ @entries.shift while @entries.size >= @max_size
76
+ end
77
+ @entries[key] = { state: clone, expires_at: now + @ttl_seconds }
78
+ end
79
+
80
+ def prune_expired_locked
81
+ t = now
82
+ @entries.delete_if { |_key, entry| entry[:expires_at] <= t }
83
+ end
84
+
85
+ def entry_key(name, context)
86
+ [context, name.to_s]
87
+ end
88
+
89
+ def now
90
+ @clock.call
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Rollout
4
+ module ActiveRecord
5
+ class Migration
6
+ class Difference
7
+ attr_reader :name, :kind, :field, :source, :destination
8
+
9
+ def initialize(name:, kind:, field: nil, source: nil, destination: nil)
10
+ @name = name
11
+ @kind = kind
12
+ @field = field
13
+ @source = source
14
+ @destination = destination
15
+ end
16
+
17
+ def ==(other)
18
+ other.is_a?(self.class) &&
19
+ name == other.name &&
20
+ kind == other.kind &&
21
+ field == other.field &&
22
+ source == other.source &&
23
+ destination == other.destination
24
+ end
25
+ end
26
+
27
+ class Result
28
+ attr_reader :status, :feature_count, :history_count, :missing_names, :unregistered_names, :differences
29
+
30
+ def initialize(
31
+ status:,
32
+ feature_count: 0,
33
+ history_count: 0,
34
+ missing_names: [],
35
+ unregistered_names: [],
36
+ differences: []
37
+ )
38
+ @status = status
39
+ @feature_count = feature_count
40
+ @history_count = history_count
41
+ @missing_names = missing_names
42
+ @unregistered_names = unregistered_names
43
+ @differences = differences
44
+ end
45
+
46
+ def success?
47
+ status == :ok || status == :ready
48
+ end
49
+
50
+ def summary
51
+ case status
52
+ when :ready
53
+ ready_summary("Ready to import")
54
+ when :ok
55
+ ready_summary("Imported")
56
+ when :source_invalid
57
+ "Source invalid: missing=#{missing_names.inspect} unregistered=#{unregistered_names.inspect}"
58
+ when :destination_conflict
59
+ "Destination already has rollout data"
60
+ when :verification_failed
61
+ verification_failed_summary
62
+ else
63
+ "Migration failed (#{status})"
64
+ end
65
+ end
66
+
67
+ private
68
+
69
+ def ready_summary(prefix)
70
+ features = "#{feature_count} feature#{'s' unless feature_count == 1}"
71
+ if history_count > 0
72
+ events = "#{history_count} history event#{'s' unless history_count == 1}"
73
+ "#{prefix} #{features} and #{events}"
74
+ else
75
+ "#{prefix} #{features}"
76
+ end
77
+ end
78
+
79
+ def verification_failed_summary
80
+ message = "Imported data did not match the source"
81
+ difference = differences.first
82
+ return message unless difference
83
+
84
+ details = [difference.kind, difference.name, difference.field].compact.join(" ")
85
+ "#{message}: #{details}"
86
+ end
87
+ end
88
+
89
+ def initialize(source:, destination:, include_history: false)
90
+ @source = source
91
+ @destination = destination
92
+ @include_history = include_history
93
+ end
94
+
95
+ def dry_run
96
+ execute(write: false)
97
+ end
98
+
99
+ def run
100
+ execute(write: true)
101
+ end
102
+
103
+ private
104
+
105
+ def execute(write:)
106
+ export = @source.export_features(include_history: @include_history)
107
+ history = @include_history ? Array(export.history) : []
108
+ counts = { feature_count: export.states.size, history_count: history.size }
109
+
110
+ unless export.valid?
111
+ return Result.new(
112
+ status: :source_invalid,
113
+ missing_names: export.missing_names,
114
+ unregistered_names: export.unregistered_names,
115
+ **counts,
116
+ )
117
+ end
118
+
119
+ if @destination.occupied?
120
+ return Result.new(status: :destination_conflict, **counts)
121
+ end
122
+
123
+ return Result.new(status: :ready, **counts) unless write
124
+
125
+ failed = nil
126
+ begin
127
+ @destination.import_features(export.states, history: history) do
128
+ differences = compare_states(export.states, imported_states) +
129
+ compare_history(history)
130
+ next if differences.empty?
131
+
132
+ failed = Result.new(status: :verification_failed, differences: differences, **counts)
133
+ raise ::ActiveRecord::Rollback
134
+ end
135
+ rescue ::Rollout::Adapters::ActiveRecord::DestinationNotEmpty
136
+ return Result.new(status: :destination_conflict, **counts)
137
+ end
138
+
139
+ failed || Result.new(status: :ok, **counts)
140
+ end
141
+
142
+ def imported_states
143
+ @destination.fetch_features(@destination.feature_names)
144
+ end
145
+
146
+ def compare_states(source_states, destination_states)
147
+ source_by_name = source_states.to_h { |state| [state.name, state] }
148
+ destination_by_name = destination_states.to_h { |state| [state.name, state] }
149
+ names = (source_by_name.keys | destination_by_name.keys).sort
150
+ differences = []
151
+
152
+ names.each do |name|
153
+ source = source_by_name[name]
154
+ destination = destination_by_name[name]
155
+
156
+ if source.nil?
157
+ differences << Difference.new(name: name, kind: :extra)
158
+ next
159
+ end
160
+
161
+ if destination.nil?
162
+ differences << Difference.new(name: name, kind: :missing)
163
+ next
164
+ end
165
+
166
+ next if source == destination
167
+
168
+ %i[percentage users groups data].each do |field|
169
+ source_value = source.public_send(field)
170
+ destination_value = destination.public_send(field)
171
+ next if source_value == destination_value
172
+
173
+ differences << Difference.new(
174
+ name: name,
175
+ kind: :changed,
176
+ field: field,
177
+ source: source_value,
178
+ destination: destination_value,
179
+ )
180
+ end
181
+ end
182
+
183
+ differences
184
+ end
185
+
186
+ def compare_history(source_entries)
187
+ source_by_feature = Hash.new { |hash, name| hash[name] = [] }
188
+ source_global = []
189
+
190
+ source_entries.each do |entry|
191
+ event = entry.event
192
+ source_by_feature[event.feature.to_s] << event if entry.feature_visible
193
+ source_global << event if entry.global_visible
194
+ end
195
+
196
+ names = (source_by_feature.keys | history_feature_names).sort
197
+ differences = []
198
+
199
+ names.each do |name|
200
+ differences.concat(
201
+ event_differences(
202
+ name,
203
+ source_by_feature[name],
204
+ @destination.feature_events(name),
205
+ ),
206
+ )
207
+ end
208
+ differences.concat(event_differences("_global_", source_global, @destination.global_events))
209
+ differences
210
+ end
211
+
212
+ def history_feature_names
213
+ @destination.global_events.map { |event| event.feature.to_s } |
214
+ @destination.feature_names.map(&:to_s)
215
+ end
216
+
217
+ def event_differences(name, source_events, destination_events)
218
+ source_signatures = source_events.map { |event| event_signature(event) }
219
+ destination_signatures = destination_events.map { |event| event_signature(event) }
220
+ return [] if source_signatures == destination_signatures
221
+
222
+ [Difference.new(
223
+ name: name,
224
+ kind: :history,
225
+ field: :events,
226
+ source: source_signatures,
227
+ destination: destination_signatures,
228
+ )]
229
+ end
230
+
231
+ def event_signature(event)
232
+ {
233
+ feature: event.feature.to_s,
234
+ name: event.name.to_s,
235
+ data: event.data,
236
+ context: event.context,
237
+ timestamp: (event.created_at.to_r * 1_000_000).round,
238
+ }
239
+ end
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Rollout
4
+ module ActiveRecord
5
+ module Schema
6
+ def self.create(connection, features_table: "rollout_features", events_table: "rollout_events")
7
+ name_options = name_column_options(connection)
8
+ payload_options = payload_column_options(connection)
9
+
10
+ connection.create_table(features_table) do |table|
11
+ table.string :name, **name_options
12
+ table.float :percentage, limit: 53, null: false, default: 0.0
13
+ table.text :users, **payload_options
14
+ table.text :groups, **payload_options
15
+ table.text :data, **payload_options
16
+ table.timestamps
17
+ end
18
+ connection.add_index(features_table, :name, unique: true)
19
+
20
+ connection.create_table(events_table) do |table|
21
+ table.string :feature_name, **name_options
22
+ table.string :event_name, null: false
23
+ table.text :data, **payload_options
24
+ table.text :context, **payload_options
25
+ table.boolean :feature_visible, null: false, default: true
26
+ table.boolean :global_visible, null: false, default: true
27
+ table.datetime :occurred_at, null: false, precision: 6
28
+ end
29
+ connection.add_index(
30
+ events_table,
31
+ [:feature_name, :feature_visible, :occurred_at],
32
+ name: "index_#{events_table}_for_feature_history",
33
+ )
34
+ connection.add_index(
35
+ events_table,
36
+ [:global_visible, :occurred_at],
37
+ name: "index_#{events_table}_for_global_history",
38
+ )
39
+ connection.add_index(
40
+ events_table,
41
+ [:feature_visible, :global_visible],
42
+ name: "index_#{events_table}_for_hidden_cleanup",
43
+ )
44
+ end
45
+
46
+ def self.drop(connection, features_table: "rollout_features", events_table: "rollout_events")
47
+ connection.drop_table(events_table, if_exists: true)
48
+ connection.drop_table(features_table, if_exists: true)
49
+ end
50
+
51
+ def self.name_column_options(connection)
52
+ options = { null: false }
53
+ options[:collation] = "utf8mb4_bin" if connection.adapter_name.match?(/mysql/i)
54
+ options
55
+ end
56
+
57
+ def self.payload_column_options(connection)
58
+ options = { null: false }
59
+ options[:limit] = 16_777_215 if connection.adapter_name.match?(/mysql/i)
60
+ options
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,386 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_record'
4
+ require 'rollout'
5
+ require 'rollout/active_record/schema'
6
+ require 'rollout/active_record/codec'
7
+ require 'rollout/active_record/migration'
8
+ require 'rollout/active_record/feature_cache'
9
+
10
+ class Rollout
11
+ module Adapters
12
+ class ActiveRecord
13
+ class DestinationNotEmpty < ArgumentError
14
+ end
15
+
16
+ def initialize(
17
+ base_record_class: ::ActiveRecord::Base,
18
+ features_table_name: "rollout_features",
19
+ events_table_name: "rollout_events",
20
+ cache_ttl_seconds: nil
21
+ )
22
+ @base_record_class = base_record_class
23
+ @features_table_name = features_table_name
24
+ @events_table_name = events_table_name
25
+ @feature_record = build_record_class(@features_table_name)
26
+ @event_record = build_record_class(@events_table_name)
27
+ @feature_cache = cache_ttl_seconds.nil? ? nil : ::Rollout::ActiveRecord::FeatureCache.new(ttl_seconds: cache_ttl_seconds)
28
+ end
29
+
30
+ def fetch_feature(name)
31
+ if use_feature_cache?
32
+ context = cache_context
33
+ cached = @feature_cache.read(name, context: context)
34
+ return cached if cached
35
+
36
+ generation = @feature_cache.generation
37
+ state = load_feature(name)
38
+ @feature_cache.fill(generation, [state], context: context)
39
+ return state
40
+ end
41
+
42
+ load_feature(name)
43
+ end
44
+
45
+ def fetch_features(names)
46
+ return [] if names.empty?
47
+ return load_features(names) unless use_feature_cache?
48
+
49
+ context = cache_context
50
+ hits, misses = partition_cached_features(names, context)
51
+ fill_feature_cache(hits, misses, context) unless misses.empty?
52
+ names.map { |name| hits[name.to_s].deep_clone }
53
+ end
54
+
55
+ def feature_names
56
+ @feature_record.order(:id).pluck(:name)
57
+ end
58
+
59
+ def feature_exists?(name)
60
+ @feature_record.exists?(name: name.to_s)
61
+ end
62
+
63
+ def save_feature(state)
64
+ @feature_record.transaction do
65
+ persist_state(locked_feature(state.name), state)
66
+ invalidate_features_after_commit(state.name)
67
+ end
68
+ end
69
+
70
+ def delete_feature(name)
71
+ @feature_record.where(name: name.to_s).delete_all
72
+ invalidate_features_after_commit(name)
73
+ end
74
+
75
+ def clear_features
76
+ @feature_record.delete_all
77
+ invalidate_all_features_after_commit
78
+ end
79
+
80
+ def occupied?
81
+ @feature_record.uncached do
82
+ @feature_record.exists? || @event_record.exists?
83
+ end
84
+ end
85
+
86
+ def import_features(states, history: [])
87
+ raise ArgumentError, "states must be an Array" unless states.is_a?(Array)
88
+ raise ArgumentError, "history must be an Array" unless history.is_a?(Array)
89
+
90
+ states.each do |state|
91
+ next if state.is_a?(::Rollout::FeatureState)
92
+
93
+ raise ArgumentError, "states must contain FeatureState objects"
94
+ end
95
+
96
+ @feature_record.transaction(requires_new: true) do
97
+ if occupied?
98
+ raise DestinationNotEmpty, "destination already has rollout data"
99
+ end
100
+
101
+ states.each { |state| persist_state(nil, state) }
102
+ history.each { |entry| insert_imported_event(entry) }
103
+ yield if block_given?
104
+ invalidate_all_features_after_commit
105
+ end
106
+ end
107
+
108
+ def mutate_feature(name)
109
+ rollback_error = nil
110
+ mutation = nil
111
+
112
+ @feature_record.transaction(requires_new: true) do
113
+ record = locked_feature(name)
114
+ begin
115
+ mutation = yield ::Rollout::ActiveRecord::Codec.feature_state(name, record)
116
+ rescue ::ActiveRecord::Rollback => error
117
+ rollback_error = error
118
+ raise
119
+ end
120
+ persist_mutation(record, mutation)
121
+ invalidate_features_after_commit(name)
122
+ end
123
+
124
+ raise rollback_error if rollback_error
125
+
126
+ mutation
127
+ end
128
+
129
+ def feature_events(name, limit: nil)
130
+ events_from(
131
+ @event_record.where(feature_name: name.to_s, feature_visible: true),
132
+ limit: limit,
133
+ )
134
+ end
135
+
136
+ def global_events(limit: nil)
137
+ events_from(
138
+ @event_record.where(global_visible: true),
139
+ limit: limit,
140
+ )
141
+ end
142
+
143
+ def feature_updated_at(name)
144
+ @event_record
145
+ .where(feature_name: name.to_s, feature_visible: true)
146
+ .order(occurred_at: :desc, id: :desc)
147
+ .limit(1)
148
+ .pick(:occurred_at)
149
+ end
150
+
151
+ def delete_feature_events(name)
152
+ @feature_record.transaction do
153
+ @event_record.where(feature_name: name.to_s, feature_visible: true)
154
+ .update_all(feature_visible: false)
155
+ delete_hidden_events
156
+ end
157
+ end
158
+
159
+ private
160
+
161
+ def partition_cached_features(names, context)
162
+ hits = {}
163
+ misses = []
164
+ names.map(&:to_s).uniq.each do |name|
165
+ cached = @feature_cache.read(name, context: context)
166
+ if cached
167
+ hits[name] = cached
168
+ else
169
+ misses << name
170
+ end
171
+ end
172
+ [hits, misses]
173
+ end
174
+
175
+ def fill_feature_cache(hits, misses, context)
176
+ generation = @feature_cache.generation
177
+ load_features(misses).each do |state|
178
+ hits[state.name] = state
179
+ end
180
+ @feature_cache.fill(generation, misses.map { |name| hits[name] }, context: context)
181
+ end
182
+
183
+ def load_feature(name)
184
+ record = with_query_cache_bypass { find_feature(name) }
185
+ ::Rollout::ActiveRecord::Codec.feature_state(name, record)
186
+ end
187
+
188
+ def load_features(names)
189
+ return [] if names.empty?
190
+
191
+ unique_names = names.map(&:to_s).uniq
192
+ records = with_query_cache_bypass do
193
+ @feature_record.where(name: unique_names).index_by(&:name)
194
+ end
195
+ names.map { |name| ::Rollout::ActiveRecord::Codec.feature_state(name, records[name.to_s]) }
196
+ end
197
+
198
+ def with_query_cache_bypass
199
+ return yield unless @feature_cache
200
+
201
+ @feature_record.uncached { yield }
202
+ end
203
+
204
+ def use_feature_cache?
205
+ @feature_cache && !@feature_record.connection.transaction_open?
206
+ end
207
+
208
+ def cache_context
209
+ @feature_record.connection_pool.object_id
210
+ end
211
+
212
+ def invalidate_features_after_commit(*names)
213
+ return unless @feature_cache
214
+
215
+ context = cache_context
216
+ after_outer_commit { @feature_cache.delete(*names, context: context) }
217
+ end
218
+
219
+ def invalidate_all_features_after_commit
220
+ return unless @feature_cache
221
+
222
+ after_outer_commit { @feature_cache.clear }
223
+ end
224
+
225
+ def after_outer_commit(&block)
226
+ txn = outermost_open_transaction
227
+ if txn.nil?
228
+ block.call
229
+ elsif txn.respond_to?(:after_commit)
230
+ txn.after_commit(&block)
231
+ else
232
+ txn.add_record(AfterCommitCallback.new(&block))
233
+ end
234
+ end
235
+
236
+ def outermost_open_transaction
237
+ connection = @feature_record.connection
238
+ return nil unless connection.transaction_open?
239
+
240
+ stack = connection.transaction_manager.instance_variable_get(:@stack)
241
+ stack&.first
242
+ end
243
+
244
+ def build_record_class(table_name)
245
+ Class.new(@base_record_class) do
246
+ self.table_name = table_name
247
+ self.inheritance_column = :_type_disabled
248
+ end
249
+ end
250
+
251
+ def find_feature(name)
252
+ @feature_record.find_by(name: name.to_s)
253
+ end
254
+
255
+ def locked_feature(name)
256
+ @feature_record.uncached do
257
+ @feature_record.lock.find_by(name: name.to_s)
258
+ end
259
+ end
260
+
261
+ def persist_mutation(record, mutation)
262
+ persist_state(record, mutation.fetch(:state))
263
+ event = mutation[:event]
264
+ return unless event
265
+
266
+ history_length = mutation.fetch(:history_length)
267
+ validate_history_length!(history_length)
268
+ insert_event(event, global: mutation[:global])
269
+ prune_feature_events(event.feature, history_length)
270
+ prune_global_events(history_length) if mutation[:global]
271
+ end
272
+
273
+ def persist_state(record, state)
274
+ attributes = {
275
+ percentage: state.percentage,
276
+ users: ::Rollout::ActiveRecord::Codec.dump(state.users),
277
+ groups: ::Rollout::ActiveRecord::Codec.dump(state.groups),
278
+ data: ::Rollout::ActiveRecord::Codec.dump(state.data),
279
+ }
280
+
281
+ if record
282
+ record.update!(attributes)
283
+ else
284
+ @feature_record.create!(attributes.merge(name: state.name.to_s))
285
+ end
286
+ end
287
+
288
+ def insert_event(event, global:)
289
+ @event_record.create!(
290
+ feature_name: event.feature.to_s,
291
+ event_name: event.name.to_s,
292
+ data: ::Rollout::ActiveRecord::Codec.dump(event.data),
293
+ context: ::Rollout::ActiveRecord::Codec.dump(event.context),
294
+ feature_visible: true,
295
+ global_visible: global ? true : false,
296
+ occurred_at: event.created_at,
297
+ )
298
+ end
299
+
300
+ def insert_imported_event(entry)
301
+ event = entry.event
302
+ @event_record.create!(
303
+ feature_name: event.feature.to_s,
304
+ event_name: event.name.to_s,
305
+ data: ::Rollout::ActiveRecord::Codec.dump(event.data),
306
+ context: ::Rollout::ActiveRecord::Codec.dump(event.context),
307
+ feature_visible: !!entry.feature_visible,
308
+ global_visible: !!entry.global_visible,
309
+ occurred_at: event.created_at,
310
+ )
311
+ end
312
+
313
+ def prune_feature_events(name, history_length)
314
+ hide_excess(
315
+ @event_record.where(feature_name: name.to_s, feature_visible: true),
316
+ :feature_visible,
317
+ history_length,
318
+ )
319
+ delete_hidden_events
320
+ end
321
+
322
+ def prune_global_events(history_length)
323
+ hide_excess(
324
+ @event_record.where(global_visible: true),
325
+ :global_visible,
326
+ history_length,
327
+ )
328
+ delete_hidden_events
329
+ end
330
+
331
+ def hide_excess(scope, column, history_length)
332
+ if history_length == 0
333
+ scope.update_all(column => false)
334
+ return
335
+ end
336
+
337
+ keep_ids = scope.order(occurred_at: :desc, id: :desc).limit(history_length).pluck(:id)
338
+ return if keep_ids.empty?
339
+
340
+ scope.where.not(id: keep_ids).update_all(column => false)
341
+ end
342
+
343
+ def delete_hidden_events
344
+ @event_record.where(feature_visible: false, global_visible: false).delete_all
345
+ end
346
+
347
+ def events_from(scope, limit:)
348
+ unless limit.nil?
349
+ raise ArgumentError, "limit must be an Integer" unless limit.is_a?(Integer)
350
+ raise ArgumentError, "limit must be >= 0" if limit < 0
351
+ return [] if limit.zero?
352
+ end
353
+
354
+ records = scope.order(occurred_at: :desc, id: :desc)
355
+ records = records.limit(limit) unless limit.nil?
356
+ records.to_a.reverse.map { |record| ::Rollout::ActiveRecord::Codec.event(record) }
357
+ end
358
+
359
+ def validate_history_length!(history_length)
360
+ unless history_length.is_a?(Integer) && history_length >= 0
361
+ raise ArgumentError, "history_length must be an Integer >= 0"
362
+ end
363
+ end
364
+
365
+ class AfterCommitCallback
366
+ def initialize(&block)
367
+ @block = block
368
+ end
369
+
370
+ def committed!(*)
371
+ @block.call
372
+ end
373
+
374
+ def before_committed!
375
+ end
376
+
377
+ def rolledback!(*)
378
+ end
379
+
380
+ def trigger_transactional_callbacks?
381
+ true
382
+ end
383
+ end
384
+ end
385
+ end
386
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rollout/adapters/active_record"
metadata ADDED
@@ -0,0 +1,89 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rollout-active_record-adapter
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - FetLife
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activerecord
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.1'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '9'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '7.1'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '9'
32
+ - !ruby/object:Gem::Dependency
33
+ name: rollout
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '3.1'
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '4'
42
+ type: :runtime
43
+ prerelease: false
44
+ version_requirements: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '3.1'
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '4'
52
+ description: Active Record adapter for the rollout gem.
53
+ email:
54
+ - dev@fetlife.com
55
+ executables: []
56
+ extensions: []
57
+ extra_rdoc_files: []
58
+ files:
59
+ - LICENSE
60
+ - lib/generators/rollout/active_record/install_generator.rb
61
+ - lib/generators/rollout/active_record/templates/create_rollout_tables.rb.tt
62
+ - lib/rollout-active_record-adapter.rb
63
+ - lib/rollout/active_record/codec.rb
64
+ - lib/rollout/active_record/feature_cache.rb
65
+ - lib/rollout/active_record/migration.rb
66
+ - lib/rollout/active_record/schema.rb
67
+ - lib/rollout/adapters/active_record.rb
68
+ homepage: https://github.com/FetLife/rollout
69
+ licenses:
70
+ - MIT
71
+ metadata: {}
72
+ rdoc_options: []
73
+ require_paths:
74
+ - lib
75
+ required_ruby_version: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: '2.7'
80
+ required_rubygems_version: !ruby/object:Gem::Requirement
81
+ requirements:
82
+ - - ">="
83
+ - !ruby/object:Gem::Version
84
+ version: '0'
85
+ requirements: []
86
+ rubygems_version: 3.6.9
87
+ specification_version: 4
88
+ summary: Active Record adapter for the rollout gem.
89
+ test_files: []