activeexperiment 0.1.0.alpha

Sign up to get free protection for your applications and to get access to all the features.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +0 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +373 -0
  5. data/lib/active_experiment/base.rb +76 -0
  6. data/lib/active_experiment/cache/active_record_cache_store.rb +116 -0
  7. data/lib/active_experiment/cache/redis_hash_cache_store.rb +68 -0
  8. data/lib/active_experiment/cache.rb +28 -0
  9. data/lib/active_experiment/caching.rb +172 -0
  10. data/lib/active_experiment/callbacks.rb +111 -0
  11. data/lib/active_experiment/capturable.rb +117 -0
  12. data/lib/active_experiment/configured_experiment.rb +74 -0
  13. data/lib/active_experiment/core.rb +177 -0
  14. data/lib/active_experiment/executed.rb +86 -0
  15. data/lib/active_experiment/execution.rb +156 -0
  16. data/lib/active_experiment/gem_version.rb +18 -0
  17. data/lib/active_experiment/instrumentation.rb +45 -0
  18. data/lib/active_experiment/log_subscriber.rb +178 -0
  19. data/lib/active_experiment/logging.rb +62 -0
  20. data/lib/active_experiment/railtie.rb +69 -0
  21. data/lib/active_experiment/rollout.rb +106 -0
  22. data/lib/active_experiment/rollouts/inactive_rollout.rb +24 -0
  23. data/lib/active_experiment/rollouts/percent_rollout.rb +84 -0
  24. data/lib/active_experiment/rollouts/random_rollout.rb +46 -0
  25. data/lib/active_experiment/rollouts.rb +127 -0
  26. data/lib/active_experiment/rspec.rb +12 -0
  27. data/lib/active_experiment/run_key.rb +55 -0
  28. data/lib/active_experiment/segments.rb +69 -0
  29. data/lib/active_experiment/test_case.rb +11 -0
  30. data/lib/active_experiment/test_helper.rb +267 -0
  31. data/lib/active_experiment/variants.rb +145 -0
  32. data/lib/active_experiment/version.rb +11 -0
  33. data/lib/active_experiment.rb +27 -0
  34. data/lib/activeexperiment.rb +8 -0
  35. data/lib/rails/generators/experiment/USAGE +12 -0
  36. data/lib/rails/generators/experiment/experiment_generator.rb +53 -0
  37. data/lib/rails/generators/experiment/templates/application_experiment.rb.tt +4 -0
  38. data/lib/rails/generators/experiment/templates/experiment.rb.tt +35 -0
  39. data/lib/rails/generators/rspec/experiment/experiment_generator.rb +20 -0
  40. data/lib/rails/generators/rspec/experiment/templates/experiment_spec.rb.tt +7 -0
  41. data/lib/rails/generators/test_unit/experiment/experiment_generator.rb +21 -0
  42. data/lib/rails/generators/test_unit/experiment/templates/experiment_test.rb.tt +9 -0
  43. metadata +118 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5809078002c2de223c3c7f1017304defd4a1c7aaa3f9ac53fc7446980657ef14
4
+ data.tar.gz: 0c954cec3f8bd0af0c399ec4e3af1f2e63a4e7841df961a2df37f761bedaa682
5
+ SHA512:
6
+ metadata.gz: b05783407e7c4bda3a6833154949250a654ccfcaec30a25330bf9407d89cd613986fbe80660913c704b266329a756626ba09b5d065826cc7d3968f594b0a32e6
7
+ data.tar.gz: 649646685dcf34cc67fc8c7de2a02dd9067778382e3266ff87f25c69a4f0256eca210d0c3d1f039b5491fbab07122070338f9de89bcf5cd70b6b55e4d4ecde95
data/CHANGELOG.md ADDED
File without changes
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2022 Jeremy Jackson
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.
data/README.md ADDED
@@ -0,0 +1,373 @@
1
+ # Active Experiment – Decide what to do next
2
+
3
+ Active Experiment is a framework for defining and running experiments. It supports using a variety of rollout and reporting strategies and/or services.
4
+
5
+ Experiments can be everything from determining which query has the best performance, to which feature gets the most engagement, to rolling out a canary version of a new api service.
6
+
7
+ Experimentation is complex. There are a lot of different ways to run experiments, and even more ways to report on them. Active Experiment is designed to be flexible enough to support a variety of use cases, but also to be consistent and easy to use.
8
+
9
+ ## Usage
10
+
11
+ Start by defining an experiment class and adding some variants to it:
12
+
13
+ ```ruby
14
+ class MyExperiment < ApplicationExperiment
15
+ variant(:red) { "red" }
16
+ variant(:blue) { "blue" }
17
+ end
18
+ ```
19
+
20
+ This experiment can be generated using the Rails generator:
21
+
22
+ ```bash
23
+ rails generate experiment my_experiment red blue
24
+ ```
25
+
26
+ Run the experiment anywhere in the application by providing it a context:
27
+
28
+ ```ruby
29
+ MyExperiment.run(current_user) # => "red" or "blue"
30
+ ```
31
+
32
+ The experiment can also be run using local scope and helpers to override the default variants:
33
+
34
+ ```ruby
35
+ MyExperiment.run(current_user) do |experiment|
36
+ experiment.on(:red) { redirect_to red_path }
37
+ experiment.on(:blue) { redirect_to blue_path }
38
+ end
39
+ ```
40
+
41
+ That's it! When this experiment is encountered by different users, half<sup>&#8224;</sup> will get the red variant, half will get the blue variant, and each will always get the same.
42
+
43
+ <small>&#8224; roughly half, for the statistically pedantic.</small>
44
+
45
+ ## Download and Installation
46
+
47
+ Add this line to your Gemfile:
48
+
49
+ ```ruby
50
+ gem "activeexperiment"
51
+ ```
52
+
53
+ Or install the latest version with RubyGems:
54
+
55
+ ```bash
56
+ gem install activeexperiment
57
+ ```
58
+
59
+ Source code can be downloaded as part of the project on GitHub:
60
+
61
+ * https://github.com/jejacks0n/activeexperiment
62
+
63
+ ## Advanced Experimentation
64
+
65
+ This area provides a high level overview of the tools that more complex experiments can benefit from.
66
+
67
+ For example, some experiments need to define a default variant (also known as a _control_) that will be assigned if the experiment is skipped:
68
+
69
+ ```ruby
70
+ class MyExperiment < ApplicationExperiment
71
+ variant(:red) { "red" }
72
+ variant(:blue) { "blue" }
73
+
74
+ # The term control is simply a convention that means the default variant, and
75
+ # any variant can be set as the default with +use_default_variant(:red)+
76
+ control { "default" }
77
+ end
78
+ ```
79
+
80
+ Callbacks can be used to hook into the lifecycle when experiments are run, and can be targeted to when a specific variant has been assigned:
81
+
82
+ ```ruby
83
+ class MyExperiment < ApplicationExperiment
84
+ control { "default" }
85
+ variant(:red) { "red" }
86
+ variant(:blue) { "blue" }
87
+
88
+ # Skipping an experiment will always assign the default variant, which could
89
+ # be nothing, but since there's a control defined, it will be used.
90
+ before_run { skip if context.admin? }
91
+
92
+ # Only invoked when the red variant has been assigned.
93
+ before_variant(:red) { puts "running the red variant" }
94
+
95
+ # Maybe there's cleanup or logging to do afterwards?
96
+ after_run { puts "run complete with the #{variant} variant" unless skipped? }
97
+ end
98
+ ```
99
+
100
+ Segment rules can be used to assign specific variants for certain cases:
101
+
102
+ ```ruby
103
+ class MyExperiment < ApplicationExperiment
104
+ control { "default" }
105
+ variant(:red) { "red" }
106
+ variant(:blue) { "blue" }
107
+
108
+ segment :admins, into: :red
109
+ segment :old_accounts, into: :control
110
+
111
+ private
112
+
113
+ def admins
114
+ context.admin?
115
+ end
116
+
117
+ def old_accounts
118
+ context.created_at < 1.year.ago
119
+ end
120
+ end
121
+ ```
122
+
123
+ ## Rollouts
124
+
125
+ Rollouts are a core concept in Active Experiment. They allow specifying how an experiment should be rolled out, and even if it should be skipped or not. For example, the default rollout in Active Experiment is percentage based and accepts distribution rules -- if no rules are provided, even distribution is used.
126
+
127
+ A rollout can implement any number of different strategies, interact with services, and can be used on a per-experiment basis.
128
+
129
+ Here's an example of using the default percent rollout with custom distribution rules:
130
+
131
+ ```ruby
132
+ class MyExperiment < ApplicationExperiment
133
+ variant(:red) { "red" }
134
+ variant(:blue) { "blue" }
135
+ variant(:green) { "green" }
136
+
137
+ # Will assign the green variant 80% of the time, red and blue 10% each.
138
+ use_rollout :percent, rules: { red: 10, blue: 10, green: 80 }
139
+ end
140
+ ```
141
+
142
+ ### Defining Custom Rollouts
143
+
144
+ Project specific rollouts can be defined and registered too. To illustrate, here's a custom rollout that inherits from the base rollout, uses a fictional feature flag library, and assigns a random variant.
145
+
146
+ ```ruby
147
+ class FeatureFlagRollout < ActiveExperiment::Rollouts::BaseRollout
148
+ def skipped_for(experiment)
149
+ !Feature.enabled?(@rollout_options[:flag_name] || experiment.name)
150
+ end
151
+
152
+ def variant_for(experiment)
153
+ experiment.variant_names.sample
154
+ end
155
+ end
156
+
157
+ ActiveExperiment::Rollouts.register(:feature_flag, FeatureRollout)
158
+ ```
159
+
160
+ This rollout can now be used the same way the built-in rollouts are:
161
+
162
+ ```ruby
163
+ class MyExperiment < ActiveExperiment::Base
164
+ variant(:red) { "red" }
165
+ variant(:blue) { "blue" }
166
+
167
+ # Using a custom rollout with options.
168
+ rollout :feature_flag, flag_name: "my_feature_flag"
169
+ end
170
+ ```
171
+
172
+ Custom rollouts can be registered to autoload as well, so they're only loaded when needed:
173
+
174
+ ```ruby
175
+ ActiveExperiment::Rollouts.register(
176
+ :feature_flag,
177
+ "lib/feature_flag_rollout.rb"
178
+ )
179
+ ```
180
+
181
+ There's a world of flexibility with custom rollouts. One creative and simple rollout concept is to use the experiment itself:
182
+
183
+ ```ruby
184
+ class MyExperiment < ActiveExperiment::Base
185
+ variant(:red) { "red" }
186
+ variant(:blue) { "blue" }
187
+
188
+ def self.skipped_for(*)
189
+ false
190
+ end
191
+
192
+ def self.variant_for(*)
193
+ variant_names.sample
194
+ end
195
+
196
+ use_rollout self
197
+ end
198
+ ```
199
+
200
+ ## Reporting
201
+
202
+ Reporting is a core concept in Active Experiment. It allows for collecting data about experiments and variants, and can be used to track performance metrics, analyze results, and more.
203
+
204
+ Some simple reporting strategies might simply be added to `after_run` callbacks, but more complex reporting strategies can be implemented using a subscriber.
205
+
206
+ A subscriber can be used to listen for experiment events and report them to a service. For example, here's a subscriber that reports to a fictional analytics service:
207
+
208
+ ```ruby
209
+ class MyAnalyticsSubscriber
210
+ def process_run(event)
211
+ experiment = event.payload[:experiment]
212
+ return if experiment.skipped?
213
+
214
+ Analytics.report(
215
+ experiment.serialize,
216
+ error: event.payload[:exception_object]
217
+ )
218
+ end
219
+ end
220
+
221
+ MyAnalyticsSubscriber.attach_to(:active_experiment)
222
+ ```
223
+
224
+ The following Active Experiment events are available for subscribers:
225
+
226
+ - `start_experiment` - The experiment has begun.
227
+ - `process_segment_callbacks` - The experiment has processed all segment rules. A variant may have been resolved through this step.
228
+ - `process_variant_steps` - An experiment variant has been run.
229
+ - `process_variant_callbacks` - The experiment has processed variant callbacks.
230
+ - `process_run_callbacks` - The experiment has processed run callbacks.
231
+ - `process_run` - The experiment has completed and can be reported on.
232
+
233
+ In each of these events, the experiment instance is available in the `event.payload` hash.
234
+
235
+ ## Experiments in Views
236
+
237
+ Experiments can be used in views, just like in any other part of your application. Sometimes though, you might want to render markup inside your run block too, and to do this, you'll need to "capture" the experiment.
238
+
239
+ To accomplish this, you can ask the experiment to capture itself by providing the view scope. The following examples (HAML or ERB) help illustrate how to avoid duplicating markup within each variant block by putting it (the container div for instance) in the run block.
240
+
241
+ Remember to include the `ActiveExperiment::Capturable` module in your experiment class:
242
+
243
+ ```ruby
244
+ class MyExperiment < ActiveExperiment::Base
245
+ include ActiveExperiment::Capturable
246
+
247
+ variant(:red) { "red" }
248
+ variant(:blue) { "blue" }
249
+ end
250
+ ```
251
+
252
+ <details>
253
+ <summary>Expand HAML example</summary>
254
+
255
+ ```haml
256
+ != MyExperiment.set(capture: self).run(current_user) do |experiment|
257
+ %div.container
258
+ = experiment.on(:red) do
259
+ %button.red-pill Red
260
+ = experiment.on(:blue) do
261
+ %button.blue-pill Blue
262
+ ```
263
+ </details>
264
+
265
+ <details>
266
+ <summary>Expand ERB example</summary>
267
+
268
+ ```erb
269
+ <%== MyExperiment.set(capture: self).run do |experiment| %>
270
+ <div class="container">
271
+ <%= experiment.on(:red) do %>
272
+ <button class="red-pill">Red</button>
273
+ <% end %>
274
+ <%= experiment.on(:blue) do %>
275
+ <button class="blue-pill">Blue</button>
276
+ <% end %>
277
+ </div>
278
+ <% end %>
279
+ ```
280
+ </details>
281
+
282
+ ## Client Side Experimentation
283
+
284
+ While Active Experiment doesn't include any specific tooling for client side experimentation at this time, it does provide the ability to surface experiments in the client layer.
285
+
286
+ Whenever an experiment is run in the request lifecycle, it's stored so it can be provided to the client. This means that if an experiment is run in controller, a view, a helper, etc. it will be available to the client.
287
+
288
+ In the layout, the experiment data can be rendered as JSON for instance:
289
+
290
+ ```erb
291
+ <title>My App</title>
292
+ <script>
293
+ window.experiments = <%== ActiveExperiment::Executed.to_json %>
294
+ </script>
295
+ ```
296
+
297
+ Or each experiment can be iterated over and rendered individually:
298
+
299
+ ```erb
300
+ <% ActiveExperiment::Executed.experiments.each do |experiment| %>
301
+ <meta name="<%= experiment.name %>" content="<%== experiment.serialize.to_json %>">
302
+ <% end %>
303
+ ```
304
+
305
+ ## Testing
306
+
307
+ Active Experiment provides a test helper that can be used to stub experiments and assert that the expected experiments have been run.
308
+
309
+ To use the test helper, include it in your test case:
310
+
311
+ ```ruby
312
+ class MyTestCase < ActiveSupport::TestCase
313
+ include ActiveExperiment::TestHelper
314
+ end
315
+ ```
316
+
317
+ Now you can stub experiments in your tests:
318
+
319
+ ```ruby
320
+ test "stubbing experiments" do
321
+ stub_experiment(MyExperiment, :red) do
322
+ # Now all MyExperiment experiments will assign the :red variant.
323
+ end
324
+
325
+ stub_experiment(MyExperiment, skip: true) do
326
+ # Now all MyExperiment experiments be skipped.
327
+ end
328
+ end
329
+ ```
330
+
331
+ Assertion helpers are also available:
332
+
333
+ ```ruby
334
+ test "asserting experiments" do
335
+ # no experiments has been run
336
+ assert_no_experiments
337
+
338
+ MyExperiment.run(id: 1)
339
+
340
+ # 1 experiment has been run
341
+ assert_experiments 1
342
+
343
+ # 2 experiments expected within the block.
344
+ assert_experiments 2 do
345
+ MyExperiment.run(id: 2)
346
+ MyExperiment.run(id: 3)
347
+ end
348
+
349
+ # assert an experiment has been run with context.
350
+ assert_experiment_with(MyExperiment, context: { id: 1 })
351
+
352
+ # experiment with context, and a variant assigned expected within the block.
353
+ assert_experiment_with(MyExperiment, variant: :red, context: { id: 4 }) do
354
+ MyExperiment.set(variant: :red).run(id: 4)
355
+ end
356
+ end
357
+ ```
358
+
359
+ RSpec support can be added by requiring `active_experiment/rspec` in the appropriate spec helper.
360
+
361
+ ## GlobalID support
362
+
363
+ Active Experiment supports [GlobalID serialization](https://github.com/rails/globalid/) for experiment contexts. This is part of what makes it possible to utilize Active Record objects as context to consistently assign the same variant across multiple runs.
364
+
365
+ ## License
366
+
367
+ Active Experiment is released under the MIT license:
368
+
369
+ * https://opensource.org/licenses/MIT
370
+
371
+ Copyright 2022 [jejacks0n](https://github.com/jejacks0n)
372
+
373
+ ## Make Code Not War
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/module/attribute_accessors"
4
+ require "active_experiment"
5
+ require "active_experiment/log_subscriber"
6
+ require "active_experiment/core"
7
+ require "active_experiment/caching"
8
+ require "active_experiment/callbacks"
9
+ require "active_experiment/execution"
10
+ require "active_experiment/instrumentation"
11
+ require "active_experiment/logging"
12
+ require "active_experiment/rollout"
13
+ require "active_experiment/run_key"
14
+ require "active_experiment/segments"
15
+ require "active_experiment/variants"
16
+
17
+ module ActiveExperiment
18
+ # = Active Experiment
19
+ #
20
+ # Active Experiment is a library that helps with defining, running, and
21
+ # reporting on experiments.
22
+ #
23
+ # In general terms, defining an experiment is done by subclassing
24
+ # +ActiveExperiment::Base+ and using the +control+ and +variant+ methods to
25
+ # register the variants of the experiment. Variants are the names of the code
26
+ # paths that will deviate from one another.
27
+ #
28
+ # An example of an experiment definition might look like:
29
+ #
30
+ # class MyExperiment < ActiveExperiment::Base
31
+ # control { }
32
+ # variant(:red) { "red" }
33
+ # variant(:blue) { "blue" }
34
+ # end
35
+ #
36
+ # The term "control" is used to refer to the default variant, or when no
37
+ # other variant is assigned. Calling it the control is largely a convention
38
+ # and is not enforced by the library.
39
+ #
40
+ # Once an experiment has been defined, it can be run in various areas of an
41
+ # application. When running an experiment, variants can be overridden, which
42
+ # allows utilizing the scope and helpers available where the experiment is
43
+ # being run.
44
+ #
45
+ # For instance, within a view it can be useful to render different partials:
46
+ #
47
+ # MyExperiment.run(current_user) do |experiment|
48
+ # experiment.on(:red) { render partial: "red" }
49
+ # experiment.on(:blue) { render partial: "blue" }
50
+ # end
51
+ #
52
+ # Or within a controller, it can be useful to redirect to different paths:
53
+ #
54
+ # MyExperiment.run(current_user) do |experiment|
55
+ # experiment.on(:red) { redirect_to red_path }
56
+ # experiment.on(:blue) { redirect_to blue_path }
57
+ # end
58
+ #
59
+ # This approach allows for the same experiment to be run in different areas
60
+ # of an application, with consistent variant assignment, even if the
61
+ # experimental behavior is different in different parts of the application.
62
+ class Base
63
+ include Core
64
+ include Caching
65
+ include Callbacks
66
+ include Execution
67
+ include Instrumentation
68
+ include Logging
69
+ include Rollout
70
+ include RunKey
71
+ include Segments
72
+ include Variants
73
+
74
+ ActiveSupport.run_load_hooks(:active_experiment, self)
75
+ end
76
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+ require "active_record"
5
+
6
+ module ActiveExperiment
7
+ module Cache
8
+ # == Active Experiment Active Record Cache Store
9
+ #
10
+ # This cache store is an implementation on top of ActiveRecord and expects
11
+ # that the cache will live until the experiment is cleaned up and removed.
12
+ #
13
+ # This is a useful but not particularly performant cache store. It's useful
14
+ # because a lot of Rails projects already have a usable ActiveRecord
15
+ # connection, and because it's likely to be a long lived datastore.
16
+ #
17
+ # This cache store doesn't use a model class directly, and instead executes
18
+ # raw sql to minimize memory usage and allocations.
19
+ #
20
+ # The data structure:
21
+ # key: experiment name : run key
22
+ # value: cache entry
23
+ #
24
+ # To use this cache in an experiment the table needs to be created. All
25
+ # experiments will use the same table by default for their cache store, and
26
+ # can be distinguishable by the experiment name that's part of the cache
27
+ # key.
28
+ #
29
+ # create_table :active_experiment_cache_entries, id: false do |t|
30
+ # t.string :key, null: false
31
+ # t.string :value, null: false
32
+ # end
33
+ #
34
+ # add_index :active_experiment_cache_entries, :key, unique: true
35
+ #
36
+ # Once a table is created, the cache store can be used in an experiment:
37
+ #
38
+ # class MyExperiment < ActiveExperiment::Base
39
+ # variant(:red) { "red" }
40
+ # variant(:blue) { "blue" }
41
+ #
42
+ # use_cache_store :active_record
43
+ # end
44
+ class ActiveRecordCacheStore < ActiveSupport::Cache::Store
45
+ DEFAULT_TABLE_NAME = "active_experiment_cache_entries"
46
+
47
+ def initialize(options = nil)
48
+ super
49
+ @connection = ActiveRecord::Base.connection
50
+ end
51
+
52
+ def length(options = nil)
53
+ options = merged_options(options)
54
+ execute(<<~SQL).first["COUNT(key)"]
55
+ SELECT COUNT(key) FROM #{table_name(options)}
56
+ SQL
57
+ end
58
+
59
+ def clear(options = nil)
60
+ options = merged_options(options)
61
+ execute(<<~SQL)
62
+ DELETE FROM #{table_name(options)}
63
+ SQL
64
+ end
65
+
66
+ def delete_matched(matcher, options = nil)
67
+ options = merged_options(options)
68
+ execute(<<~SQL, key_matcher(matcher, options))
69
+ DELETE FROM #{table_name(options)} WHERE key LIKE $1
70
+ SQL
71
+ end
72
+
73
+ private
74
+ def read_entry(key, **options)
75
+ results = execute(<<~SQL, key)&.first.try(:[], "value")
76
+ SELECT value FROM #{table_name(options)} WHERE key = $1
77
+ SQL
78
+
79
+ deserialize_entry(results)
80
+ end
81
+
82
+ def write_entry(key, entry, **options)
83
+ return false if options[:unless_exist] && exist?(key, options)
84
+
85
+ execute(<<~SQL, key, serialize_entry(entry, **options))
86
+ INSERT INTO #{table_name(options)} (key, value) VALUES ($1, $2)
87
+ SQL
88
+
89
+ true
90
+ end
91
+
92
+ def delete_entry(key, **options)
93
+ execute(<<~SQL, key)
94
+ DELETE FROM #{table_name(options)} WHERE key = $1
95
+ SQL
96
+
97
+ true
98
+ end
99
+
100
+ def table_name(options)
101
+ options[:table_name] || DEFAULT_TABLE_NAME
102
+ end
103
+
104
+ def key_matcher(source, options)
105
+ source = "#{source}%"
106
+
107
+ return source unless options[:namespace]
108
+ namespace_key(source, options)
109
+ end
110
+
111
+ def execute(sql, *args, prepare: true, **kws, &block)
112
+ @connection.exec_query(sql, "SQL", args, prepare: prepare, **kws, &block)
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/cache/redis_cache_store"
4
+
5
+ module ActiveExperiment
6
+ module Cache
7
+ # == Active Experiment Redis Hash Cache Store
8
+ #
9
+ # This cache store is an implementation on top of the redis hash data type
10
+ # (https://redis.io/docs/data-types/hashes/) and expects that the cache
11
+ # will live until the experiment is cleaned up and removed.
12
+ #
13
+ # This is a good cache store to use with Active Experiment because of the
14
+ # optimized way that redis stores hashes.
15
+ #
16
+ # The data structure:
17
+ # key: experiment name
18
+ # fields: key => entry
19
+ #
20
+ # To use this cache in an experiment:
21
+ #
22
+ # class MyExperiment < ActiveExperiment::Base
23
+ # variant(:red) { "red" }
24
+ # variant(:blue) { "blue" }
25
+ #
26
+ # use_cache_store :redis_hash
27
+ # end
28
+ class RedisHashCacheStore < ActiveSupport::Cache::RedisCacheStore
29
+ def length(hkey = nil)
30
+ if hkey
31
+ failsafe :read_hlen do
32
+ redis.then { |c| c.hlen(hkey) }
33
+ end
34
+ else
35
+ failsafe :read_dbsize do
36
+ redis.then { |c| c.dbsize }
37
+ end
38
+ end
39
+ end
40
+
41
+ private
42
+ def hkey(key)
43
+ parts = key.to_s.split(":")
44
+ run_key = parts.pop
45
+ [Array(parts).join(":"), run_key]
46
+ end
47
+
48
+ def read_serialized_entry(key, raw: false, **options)
49
+ failsafe :read_entry do
50
+ redis.then { |c| c.hget(*hkey(key)) }
51
+ end
52
+ end
53
+
54
+ def write_serialized_entry(key, payload, raw: false, unless_exist: false, expires_in: nil, race_condition_ttl: nil, pipeline: nil, **options)
55
+ # TODO: Support pipeline?
56
+ failsafe :write_entry, returning: false do
57
+ redis.then { |c| c.hset(*hkey(key), payload) }
58
+ end
59
+ end
60
+
61
+ def delete_entry(key, **options)
62
+ failsafe :delete_entry, returning: false do
63
+ redis.then { |c| c.hdel(*hkey(key)) }
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveExperiment
4
+ # == Cache Stores
5
+ #
6
+ # TODO: finish documenting.
7
+ module Cache
8
+ extend ActiveSupport::Autoload
9
+
10
+ autoload :ActiveRecordCacheStore
11
+ autoload :RedisHashCacheStore
12
+
13
+ CACHE_STORE_SUFFIX = "CacheStore"
14
+ private_constant :CACHE_STORE_SUFFIX
15
+
16
+ # Allows looking up a cache store by name.
17
+ #
18
+ # Raises an +ArgumentError+ if the cache store isn't found.
19
+ def self.lookup(name, *args, **options)
20
+ const_get("#{name.to_s.camelize}#{CACHE_STORE_SUFFIX}").new(*args, **options)
21
+ rescue NameError
22
+ store = ActiveSupport::Cache.lookup_store(name, *args, **options)
23
+ raise ArgumentError, "No cache store found for #{name.inspect}" unless store
24
+
25
+ store
26
+ end
27
+ end
28
+ end