feature_switches 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: d9320af56d6df754066bdf4db823d5c5664177df
4
+ data.tar.gz: 25c88dab2729ecbe5e7e3f65a93d9ecdda97ad22
5
+ SHA512:
6
+ metadata.gz: 00215b33937508434dca51ab4266de7ee4b54a689695e8ab4a3bacb5ce87dc02f8732f460e279287b92f2d45a74fb5d93a451818f016a205f25f7582af48311d
7
+ data.tar.gz: cb7f70b4d6a3778dc18db84c2f737ebbc1cf4aaa69cdf6fa2c5efefb4e81db5ff6891b9d70415bb52238254696247ad16ba405ec776776df55d56868782a9310
data/README.md ADDED
@@ -0,0 +1,122 @@
1
+ # Feature Switches
2
+
3
+ ### Status: Alpha (Don't use in production)
4
+
5
+ ## Summary
6
+
7
+ A small gem for putting feature switches into your application that allow you
8
+ to dynamically enable features to a percentage of users or specific cohort
9
+ groups without a code deploy.
10
+
11
+ There are some excellent gems that provide this functionality already --
12
+ [rollout](https://github.com/jamesgolick/rollout) and [flipper](https://github.com/jnunemaker/flipper).
13
+ This project is an experiment aiming for a specific set of design goals:
14
+
15
+ 1) Reduce the chatter in the protocol between a node checking to see if a
16
+ feature is enabled and the backend storage system. We're going to be reading
17
+ this data far more often than we're writing it so we want to aggressively
18
+ cache, but...
19
+
20
+ 2) Ensure all nodes get the latest configured switches immediately. A cache
21
+ that expires given a certain TTL can't work as a client isn't guaranteed
22
+ to talking to the same instance of our application on each request. A
23
+ feature disappearing and reappearing depending on which application server
24
+ we instance we hit is a bug.
25
+
26
+ 3) Allow for extension with new backends that support change notification;
27
+ specifically distributed systems synchronization backends like Zookeeper and
28
+ perhaps doozerd. Right now it's initially implemented against Redis which is
29
+ the simplest thing that could possibly work.
30
+
31
+ 5) Ensure that any kind of identifier can be used; not just an object that
32
+ responds to #id. We want to peg our switches on things that aren't
33
+ ActiveRecord objects (e.g., incoming phone numbers, time of day, etc).
34
+
35
+ 4) Expose a highly memorable CLI as that's how we're going to be interacting
36
+ with the switches via `irb`.
37
+
38
+
39
+ ## Supported Backends
40
+
41
+ * Redis
42
+ * Postgres
43
+ * In-memory (for testing)
44
+
45
+ ## Design
46
+
47
+ Switches uses a backend for both storage of feature configuration data and for
48
+ notifying sibling nodes that are change has occurred. We'll look at how this
49
+ works against the Redis backend.
50
+
51
+ On startup, switches will connect to Redis twice: once for querying and setting
52
+ configuration data and one for subscribing to a pub/sub channel of change
53
+ notifications. When a change is made to configuration data, an extra call is
54
+ made to Redis to publish a change notification. Once this change notification is
55
+ received by other listening nodes, they will refetch the configuration data
56
+ and update their local stores.
57
+
58
+
59
+ Node A Redis Node B
60
+ | | |
61
+ | |<-subscribe----|
62
+ | | |
63
+ |------------set->| |
64
+ | | |
65
+ |-publish(update)>|-------update->|
66
+ | | |
67
+ | |<-get----------|
68
+ | | |
69
+ | | |
70
+
71
+ ## Installation
72
+
73
+ In your Gemfile:
74
+
75
+ gem "feature_switches"
76
+
77
+ ## Usage
78
+
79
+ ```ruby
80
+ # Initialize
81
+ $switches = Switches do |config|
82
+ config.backend = "redis://localhost:6379/0"
83
+ end
84
+ => #<Switches redis://localhost:6379/12>
85
+
86
+ # Check to see if a feature is active for an identifier
87
+ $switches.feature(:redesign).on?(current_user.id)
88
+ # => true
89
+
90
+ $switches.feature(:redesign).on?(current_user.phone_number)
91
+ # => true
92
+
93
+ $switches.feature(:redesign).on?(Time.now.hour)
94
+ # => true
95
+
96
+ # Turn a feature on globally
97
+ $switches.feature(:redesign).on
98
+ # => #<Feature redesign; 100%>
99
+
100
+ # Turn a feature on for a given percentage of identifiers
101
+ $switches.feature(:redesign).on(25)
102
+ # => #<Feature redesign; 25%>
103
+
104
+ # Turn a feature off globally
105
+ $switches.feature(:redesign).off
106
+ # => #<Feature redesign; 0%>
107
+
108
+ # Add or remove an identifier from a cohort group
109
+ $switches.cohort(:power_users).add(424)
110
+ # => #<Cohort power_users; 1 member>
111
+
112
+ $switches.cohort(:power_users).remove(424)
113
+ # => #<Cohort power_users; 0 members>
114
+
115
+ # Add a cohort group to a feature
116
+ $switches.feature(:redesign).add(:power_users)
117
+ # => #<Feature djsd; 0%; power_users>
118
+
119
+ # Remove a cohort group from a feature
120
+ $switches.feature(:redesign).remove(:power_users)
121
+ # => #<Feature djsd; 0%>
122
+ ```
@@ -0,0 +1,17 @@
1
+ require "uri"
2
+
3
+ module Switches
4
+ class Backend
5
+ def self.factory(url, instance)
6
+ uri = URI(url)
7
+
8
+ if uri.scheme == "redis"
9
+ Backends::Redis.new(uri, instance)
10
+ elsif uri.scheme == "postgres"
11
+ Backends::Postgres.new(uri, instance)
12
+ else
13
+ Backends::Memory.new(uri, instance)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,48 @@
1
+ module Switches
2
+ module Backends
3
+ class Memory
4
+ def initialize(uri, instance)
5
+ @instance = instance
6
+ $switches_data ||= {}
7
+ $switches_listeners ||= Set.new
8
+ end
9
+
10
+ def set(item)
11
+ $switches_data[key_for(item)] = item.to_json
12
+ end
13
+
14
+ def get(item)
15
+ if data = $switches_data[key_for(item)]
16
+ parse(data)
17
+ end
18
+ end
19
+
20
+ def listen
21
+ $switches_listeners.add(@instance)
22
+ end
23
+
24
+ def notify(update)
25
+ $switches_listeners.each do |listener|
26
+ listener.notified(update)
27
+ end
28
+ end
29
+
30
+ def clear
31
+ $switches_data.clear
32
+ $switches_listeners.clear
33
+ end
34
+
35
+ private
36
+
37
+ def key_for(item)
38
+ [item.class.to_s.downcase, item.name].join(":")
39
+ end
40
+
41
+ def parse(json)
42
+ JSON.parse(json.to_s)
43
+ rescue JSON::ParserError
44
+ {}
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,100 @@
1
+ require "pg"
2
+
3
+ module Switches
4
+ module Backends
5
+ class Postgres
6
+ TABLE = "switches"
7
+
8
+ def initialize(uri, instance)
9
+ @uri = uri
10
+ @instance = instance
11
+ end
12
+
13
+ def set(item)
14
+ result = connection.exec("UPDATE #{TABLE} SET value = $1 WHERE key = $2",
15
+ [item.to_json, key_for(item)]
16
+ )
17
+
18
+ if result.cmd_tuples == 0
19
+ connection.exec("INSERT INTO #{TABLE} (key, value) values($1, $2)",
20
+ [key_for(item), item.to_json]
21
+ )
22
+ end
23
+ end
24
+
25
+ def get(item)
26
+ result = connection.exec("SELECT value FROM #{TABLE} WHERE key = $1 LIMIT 1",
27
+ [key_for(item)]
28
+ )
29
+
30
+ result.each do |row|
31
+ return parse(row["value"])
32
+ end
33
+
34
+ nil
35
+ end
36
+
37
+ def listen
38
+ Thread.new { subscribe }
39
+ end
40
+
41
+ def notify(update)
42
+ connection.exec("NOTIFY #{TABLE}, '#{update.to_json}'")
43
+ end
44
+
45
+ def clear
46
+ connection.exec("TRUNCATE TABLE #{TABLE}")
47
+ end
48
+
49
+ private
50
+
51
+ def listener
52
+ @listener ||= connect
53
+ end
54
+
55
+ def connection
56
+ @connection ||= connect
57
+ end
58
+
59
+ def connect
60
+ PG.connect(connection_options)
61
+ end
62
+
63
+ def connection_options
64
+ {
65
+ user: @uri.user,
66
+ password: @uri.password,
67
+ host: @uri.host,
68
+ port: @uri.port,
69
+ dbname: @uri.path[1..-1]
70
+ }
71
+ end
72
+
73
+ def subscribe
74
+ loop do
75
+ listener.exec("LISTEN #{TABLE}")
76
+ listener.wait_for_notify do |event, pid, message|
77
+ process(message)
78
+ end
79
+ end
80
+ end
81
+
82
+ def key_for(item)
83
+ [item.type, item.name].join(":")
84
+ end
85
+
86
+ def parse(json)
87
+ JSON.parse(json.to_s)
88
+ rescue JSON::ParserError
89
+ {}
90
+ end
91
+
92
+ def process(message)
93
+ attributes = parse(message)
94
+ update = Update.new(attributes)
95
+
96
+ @instance.notified(update)
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,74 @@
1
+ require "redis"
2
+
3
+ module Switches
4
+ module Backends
5
+ class Redis
6
+ PREFIX = "switches"
7
+ CHANNEL = [PREFIX, "bus"].join(":")
8
+
9
+ def initialize(uri, instance)
10
+ @uri = uri
11
+ @instance = instance
12
+ end
13
+
14
+ def set(item)
15
+ connection.set(key_for(item), item.to_json)
16
+ end
17
+
18
+ def get(item)
19
+ if data = connection.get(key_for(item))
20
+ parse(data)
21
+ end
22
+ end
23
+
24
+ def listen
25
+ Thread.new { subscribe }
26
+ end
27
+
28
+ def notify(update)
29
+ connection.publish(CHANNEL, update.to_json)
30
+ end
31
+
32
+ def clear
33
+ connection.flushdb
34
+ end
35
+
36
+ private
37
+
38
+ def listener
39
+ @listener ||= connect
40
+ end
41
+
42
+ def connection
43
+ @connection ||= connect
44
+ end
45
+
46
+ def connect
47
+ ::Redis.new(url: @uri)
48
+ end
49
+
50
+ def subscribe
51
+ listener.subscribe(CHANNEL) do |on|
52
+ on.message { |_, message| process(message) }
53
+ end
54
+ end
55
+
56
+ def key_for(item)
57
+ [item.type, item.name].join(":")
58
+ end
59
+
60
+ def parse(json)
61
+ JSON.parse(json.to_s)
62
+ rescue JSON::ParserError
63
+ {}
64
+ end
65
+
66
+ def process(message)
67
+ attributes = parse(message)
68
+ update = Update.new(attributes)
69
+
70
+ @instance.notified(update)
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,63 @@
1
+ module Switches
2
+ class Cohort
3
+ attr_reader :name
4
+
5
+ def initialize(name, instance)
6
+ @name = name
7
+ @instance = instance
8
+ @members = Set.new
9
+ end
10
+
11
+ def reload
12
+ if data = @instance.get(self)
13
+ @members = data["members"].to_set
14
+ end
15
+
16
+ self
17
+ end
18
+
19
+ def include?(identifier)
20
+ @members.include?(identifier)
21
+ end
22
+
23
+ def add(member)
24
+ @members.add(member.to_s)
25
+ updated
26
+ end
27
+
28
+ def remove(member)
29
+ @members.delete(member.to_s)
30
+ updated
31
+ end
32
+
33
+ def inspect
34
+ output = "#<Cohort #{@name}"
35
+ output += "; #{@members.count} member"
36
+ output += "s" unless @members.count == 1
37
+ output += ">"
38
+ end
39
+
40
+ def to_json
41
+ {
42
+ name: name,
43
+ members: members
44
+ }.to_json
45
+ end
46
+
47
+ def members
48
+ @members.to_a
49
+ end
50
+
51
+ def type
52
+ "cohort"
53
+ end
54
+
55
+ private
56
+
57
+ def updated
58
+ @instance.set(self)
59
+ @instance.notify(self)
60
+ self
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,14 @@
1
+ module Switches
2
+ class Cohorts
3
+ def initialize(instance)
4
+ @cohorts ||= Hash.new do |cohorts, name|
5
+ name = name.to_sym
6
+ cohorts[name] = Cohort.new(name, instance).reload
7
+ end
8
+ end
9
+
10
+ def [](name)
11
+ @cohorts[name.to_sym]
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,9 @@
1
+ module Switches
2
+ class Configuration
3
+ attr_accessor :backend
4
+
5
+ def initialize
6
+ yield self if block_given?
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,89 @@
1
+ module Switches
2
+ class Feature
3
+ attr_reader :name, :percentage
4
+
5
+ def initialize(name, instance)
6
+ @name = name
7
+ @instance = instance
8
+ @percentage = Percentage(0)
9
+ @cohorts = Set.new
10
+ end
11
+
12
+ def reload
13
+ if data = @instance.get(self)
14
+ @percentage = Percentage(data["percentage"])
15
+ @cohorts = data["cohorts"].to_set
16
+ end
17
+
18
+ self
19
+ end
20
+
21
+ def on(numeric = 100)
22
+ @percentage = Percentage(numeric)
23
+ updated
24
+ end
25
+
26
+ def off
27
+ @percentage = Percentage(0)
28
+ updated
29
+ end
30
+
31
+ def add(cohort_name)
32
+ @cohorts.add(cohort_name.to_s)
33
+ updated
34
+ end
35
+
36
+ def remove(cohort_name)
37
+ @cohorts.delete(cohort_name.to_s)
38
+ updated
39
+ end
40
+
41
+ def inspect
42
+ output = "#<Feature #{@name}; #{@percentage}"
43
+ output += "; #{@cohorts.to_a.join(", ")}" if @cohorts.any?
44
+ output += ">"
45
+ end
46
+
47
+ def to_json
48
+ {
49
+ name: name,
50
+ percentage: percentage.to_i,
51
+ cohorts: @cohorts.to_a
52
+ }.to_json
53
+ end
54
+
55
+ def cohorts
56
+ @cohorts.to_a
57
+ end
58
+
59
+ def on?(identifier)
60
+ return true if @percentage.max?
61
+
62
+ in_cohort?(identifier) || in_percentage?(identifier)
63
+ end
64
+
65
+ def type
66
+ "feature"
67
+ end
68
+
69
+ private
70
+
71
+ def in_cohort?(identifier)
72
+ @cohorts.any? do |cohort|
73
+ @instance.cohort(cohort).include?(identifier)
74
+ end
75
+ end
76
+
77
+ def in_percentage?(identifier)
78
+ return false if @percentage.min?
79
+
80
+ @percentage.include?(identifier)
81
+ end
82
+
83
+ def updated
84
+ @instance.set(self)
85
+ @instance.notify(self)
86
+ self
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,14 @@
1
+ module Switches
2
+ class Features
3
+ def initialize(instance)
4
+ @features ||= Hash.new do |features, name|
5
+ name = name.to_sym
6
+ features[name] = Feature.new(name, instance).reload
7
+ end
8
+ end
9
+
10
+ def [](name)
11
+ @features[name.to_sym]
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,86 @@
1
+ module Switches
2
+ class Instance
3
+ include MonitorMixin
4
+
5
+ attr_reader :node_id
6
+
7
+ def initialize(configuration)
8
+ @url = configuration.backend
9
+ @node_id = SecureRandom.hex(3)
10
+
11
+ mon_initialize
12
+ end
13
+
14
+ def start
15
+ backend.listen
16
+ self
17
+ end
18
+
19
+ def feature(name)
20
+ synchronize do
21
+ features[name]
22
+ end
23
+ end
24
+
25
+ def cohort(name)
26
+ synchronize do
27
+ cohorts[name]
28
+ end
29
+ end
30
+
31
+ def get(item)
32
+ backend.get(item)
33
+ end
34
+
35
+ def set(item)
36
+ backend.set(item)
37
+ end
38
+
39
+ def notify(item)
40
+ Switches::Update.new.tap do |update|
41
+ update.type = item.type
42
+ update.name = item.name
43
+ update.node_id = node_id
44
+
45
+ backend.notify(update)
46
+ end
47
+ end
48
+
49
+ def notified(update)
50
+ return if update.from?(node_id)
51
+
52
+ case update.type
53
+ when "feature"
54
+ synchronize do
55
+ features[update.name].reload
56
+ end
57
+ when "cohort"
58
+ synchronize do
59
+ cohorts[update.name].reload
60
+ end
61
+ end
62
+ end
63
+
64
+ def clear
65
+ backend.clear
66
+ end
67
+
68
+ def inspect
69
+ "#<Switches #{@url}>"
70
+ end
71
+
72
+ private
73
+
74
+ def backend
75
+ @backend ||= Backend.factory(@url, self)
76
+ end
77
+
78
+ def features
79
+ @features ||= Features.new(self)
80
+ end
81
+
82
+ def cohorts
83
+ @cohorts ||= Cohorts.new(self)
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,58 @@
1
+ module Switches
2
+ class Percentage
3
+ LOWER = 0.0
4
+ UPPER = 100.0
5
+
6
+ include Comparable
7
+
8
+ attr_reader :value
9
+ protected :value
10
+
11
+ def initialize(value)
12
+ @value = clip(value)
13
+ end
14
+
15
+ def <=>(other)
16
+ @value <=> other.value
17
+ end
18
+
19
+ def include?(identifier)
20
+ Percentage(Zlib.crc32(identifier) % UPPER) < self
21
+ end
22
+
23
+ def to_i
24
+ @value.to_i
25
+ end
26
+
27
+ def to_f
28
+ @value.to_f
29
+ end
30
+
31
+ def to_s
32
+ "#{@value}%"
33
+ end
34
+
35
+ def inspect
36
+ to_s
37
+ end
38
+
39
+ def min?
40
+ @value == LOWER
41
+ end
42
+
43
+ def max?
44
+ @value == UPPER
45
+ end
46
+
47
+ private
48
+
49
+ def clip(value)
50
+ [LOWER, value.to_i, UPPER].sort[1]
51
+ end
52
+ end
53
+ end
54
+
55
+ def Percentage(value)
56
+ return value if value.kind_of?(Switches::Percentage)
57
+ Switches::Percentage.new(value)
58
+ end
@@ -0,0 +1,23 @@
1
+ module Switches
2
+ class Update
3
+ attr_accessor :type, :name, :node_id
4
+
5
+ def initialize(attributes = {})
6
+ @type = attributes["type"]
7
+ @name = attributes["name"]
8
+ @node_id = attributes["node_id"]
9
+ end
10
+
11
+ def from?(node_id)
12
+ @node_id == node_id
13
+ end
14
+
15
+ def to_json
16
+ {
17
+ "type" => type,
18
+ "name" => name,
19
+ "node_id" => node_id
20
+ }.to_json
21
+ end
22
+ end
23
+ end
data/lib/switches.rb ADDED
@@ -0,0 +1,29 @@
1
+ require "securerandom"
2
+ require "thread"
3
+ require "monitor"
4
+ require "json"
5
+ require "set"
6
+ require "zlib"
7
+
8
+ require "switches/configuration"
9
+ require "switches/instance"
10
+ require "switches/cohorts"
11
+ require "switches/cohort"
12
+ require "switches/features"
13
+ require "switches/feature"
14
+ require "switches/percentage"
15
+ require "switches/update"
16
+ require "switches/backends/redis"
17
+ require "switches/backends/memory"
18
+ require "switches/backends/postgres"
19
+ require "switches/backend"
20
+
21
+ Thread.abort_on_exception = true
22
+
23
+ def Switches
24
+ configuration = Switches::Configuration.new
25
+ yield configuration
26
+
27
+ instance = Switches::Instance.new(configuration)
28
+ instance.start
29
+ end
metadata ADDED
@@ -0,0 +1,72 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: feature_switches
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - John Pignata
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2013-04-11 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rspec
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - '>='
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - '>='
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ description: Feature switches for applications that run on multiple nodes that uses
28
+ a not-very-chatty protocol
29
+ email:
30
+ - john@pignata.com
31
+ executables: []
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - lib/switches/backend.rb
36
+ - lib/switches/backends/memory.rb
37
+ - lib/switches/backends/postgres.rb
38
+ - lib/switches/backends/redis.rb
39
+ - lib/switches/cohort.rb
40
+ - lib/switches/cohorts.rb
41
+ - lib/switches/configuration.rb
42
+ - lib/switches/feature.rb
43
+ - lib/switches/features.rb
44
+ - lib/switches/instance.rb
45
+ - lib/switches/percentage.rb
46
+ - lib/switches/update.rb
47
+ - lib/switches.rb
48
+ - README.md
49
+ homepage: http://github.com/jpignata/switches
50
+ licenses: []
51
+ metadata: {}
52
+ post_install_message:
53
+ rdoc_options: []
54
+ require_paths:
55
+ - lib
56
+ required_ruby_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - '>='
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ required_rubygems_version: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - '>='
64
+ - !ruby/object:Gem::Version
65
+ version: '0'
66
+ requirements: []
67
+ rubyforge_project:
68
+ rubygems_version: 2.0.0
69
+ signing_key:
70
+ specification_version: 4
71
+ summary: Feature switches
72
+ test_files: []