feature_switches 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
+ 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: []