feature_switches 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: d9320af56d6df754066bdf4db823d5c5664177df
4
- data.tar.gz: 25c88dab2729ecbe5e7e3f65a93d9ecdda97ad22
3
+ metadata.gz: ef07bad4705d60fcc441c0a2964525369464ca8a
4
+ data.tar.gz: 0613e9277ad7cbcc269b64847ad923050402c0d6
5
5
  SHA512:
6
- metadata.gz: 00215b33937508434dca51ab4266de7ee4b54a689695e8ab4a3bacb5ce87dc02f8732f460e279287b92f2d45a74fb5d93a451818f016a205f25f7582af48311d
7
- data.tar.gz: cb7f70b4d6a3778dc18db84c2f737ebbc1cf4aaa69cdf6fa2c5efefb4e81db5ff6891b9d70415bb52238254696247ad16ba405ec776776df55d56868782a9310
6
+ metadata.gz: 534fddb4b93385a0b35a97a617fa052acf0c062babbea1eb322d0e8ab640eae8883f972acc5a1e6bfd4b0848248498ac21a646398fba4ee771f22d84ab65e80f
7
+ data.tar.gz: ad3bfa86fde5d978ca72ffcb1bd2c2ceb4bfefea28b3badc894db829c485eca6aa95eace50ccb126db83adf51bcf62756877639833d90161a60cd23a0ffc107a
data/LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2013 John Pignata
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
4
+ this software and associated documentation files (the "Software"), to deal in
5
+ the Software without restriction, including without limitation the rights to
6
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
7
+ of the Software, and to permit persons to whom the Software is furnished to do
8
+ so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.
data/README.md CHANGED
@@ -6,35 +6,33 @@
6
6
 
7
7
  A small gem for putting feature switches into your application that allow you
8
8
  to dynamically enable features to a percentage of users or specific cohort
9
- groups without a code deploy.
9
+ groups without a code deploy. There are some excellent gems that provide this
10
+ functionality already -- [rollout](https://github.com/jamesgolick/rollout) and
11
+ [flipper](https://github.com/jnunemaker/flipper). This project is an
12
+ experiment aiming for a specific set of design goals:
10
13
 
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
14
+ 1. Reduce the chatter in the protocol between a node checking to see if a
16
15
  feature is enabled and the backend storage system. We're going to be reading
17
16
  this data far more often than we're writing it so we want to aggressively
18
17
  cache, but...
19
18
 
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.
19
+ 2. Ensure all nodes get the latest configuration data as soon as possible. A
20
+ cache that expires given a certain TTL can't work as a client isn't
21
+ guaranteed to talking to the same instance of our application on each
22
+ request. A feature disappearing and reappearing depending on which
23
+ application server instance a user hits is a bug.
30
24
 
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).
25
+ 3. Allow for extension with new backends that support change notification;
26
+ specifically distributed system synchronization backends like Zookeeper and
27
+ perhaps doozerd. Right now it's initially implemented against Redis and
28
+ Postgres.
34
29
 
35
- 4) Expose a highly memorable CLI as that's how we're going to be interacting
36
- with the switches via `irb`.
30
+ 4. Ensure that any kind of identifier can be used; not just an object that
31
+ responds to `id` We want to peg our switches on things that aren't
32
+ ActiveRecord objects (e.g., incoming phone numbers, etc).
37
33
 
34
+ 5. Expose a memorable CLI as `irb` is primarily how we configure the feature
35
+ switches.
38
36
 
39
37
  ## Supported Backends
40
38
 
@@ -45,34 +43,67 @@ This project is an experiment aiming for a specific set of design goals:
45
43
  ## Design
46
44
 
47
45
  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
46
+ notifying sibling nodes that a change has been made. We'll look at how this
49
47
  works against the Redis backend.
50
48
 
51
49
  On startup, switches will connect to Redis twice: once for querying and setting
52
50
  configuration data and one for subscribing to a pub/sub channel of change
53
51
  notifications. When a change is made to configuration data, an extra call is
54
52
  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
53
+ received by other listening nodes they will refetch the configuration data
56
54
  and update their local stores.
57
55
 
58
-
59
- Node A Redis Node B
60
- | | |
61
- | |<-subscribe----|
62
- | | |
63
- |------------set->| |
64
- | | |
65
- |-publish(update)>|-------update->|
66
- | | |
67
- | |<-get----------|
68
- | | |
69
- | | |
56
+ Node A Redis Node B
57
+ | | |
58
+ | |<-subscribe--------|
59
+ | | |
60
+ |--------------set->| |
61
+ | | |
62
+ |-----notify(item)->|-notified(update)->|
63
+ | | |
64
+ | |<-get--------------|
65
+ | | |
66
+ | | |
67
+
68
+ This allows a node to validate if a user can pass through a feature switch using
69
+ in-memory data without a querying a backend but ensures that each node is using
70
+ the same data to make the decision.
70
71
 
71
72
  ## Installation
72
73
 
73
74
  In your Gemfile:
74
75
 
76
+ ```ruby
75
77
  gem "feature_switches"
78
+ ```
79
+
80
+ ### Postgres Backend
81
+
82
+ Note that switches will connect to Postgres twice for each node. This is important
83
+ as Postgres will fork a new process for each connection so ensure you have the
84
+ overhead before using this backend.
85
+
86
+ To use Postgres a table called `switches` must be created in your database.
87
+ Two rake tasks have been included to create and drop this table:
88
+
89
+ 1. Add this to your Rakefile:
90
+
91
+ ```ruby
92
+ require "switches"
93
+ require "switches/tasks"
94
+ ```
95
+
96
+ 2. To create the table:
97
+
98
+ ```sh
99
+ DATABASE_URL=postgres://root:sekret@localhost/my_application rake switches:postgres:setup
100
+ ```
101
+
102
+ 3. To drop the table
103
+
104
+ ```sh
105
+ DATABASE_URL=postgres://root:sekret@localhost/my_application rake switches:postgres:remove
106
+ ```
76
107
 
77
108
  ## Usage
78
109
 
@@ -81,7 +112,7 @@ In your Gemfile:
81
112
  $switches = Switches do |config|
82
113
  config.backend = "redis://localhost:6379/0"
83
114
  end
84
- => #<Switches redis://localhost:6379/12>
115
+ # => #<Switches redis://localhost:6379/0>
85
116
 
86
117
  # Check to see if a feature is active for an identifier
87
118
  $switches.feature(:redesign).on?(current_user.id)
@@ -90,9 +121,6 @@ $switches.feature(:redesign).on?(current_user.id)
90
121
  $switches.feature(:redesign).on?(current_user.phone_number)
91
122
  # => true
92
123
 
93
- $switches.feature(:redesign).on?(Time.now.hour)
94
- # => true
95
-
96
124
  # Turn a feature on globally
97
125
  $switches.feature(:redesign).on
98
126
  # => #<Feature redesign; 100%>
@@ -114,9 +142,13 @@ $switches.cohort(:power_users).remove(424)
114
142
 
115
143
  # Add a cohort group to a feature
116
144
  $switches.feature(:redesign).add(:power_users)
117
- # => #<Feature djsd; 0%; power_users>
145
+ # => #<Feature redesign; 0%; power_users>
118
146
 
119
147
  # Remove a cohort group from a feature
120
148
  $switches.feature(:redesign).remove(:power_users)
121
- # => #<Feature djsd; 0%>
149
+ # => #<Feature redesign; 0%>
122
150
  ```
151
+
152
+ ## License
153
+
154
+ Please see LICENSE.
@@ -1,5 +1,3 @@
1
- require "uri"
2
-
3
1
  module Switches
4
2
  class Backend
5
3
  def self.factory(url, instance)
@@ -0,0 +1,25 @@
1
+ module Switches
2
+ module Backends
3
+ class Memory
4
+ class Bus
5
+ def initialize
6
+ @listeners = []
7
+ end
8
+
9
+ def subscribe(&block)
10
+ @listeners.push(block)
11
+ end
12
+
13
+ def publish(data)
14
+ @listeners.each do |listener|
15
+ listener.call(data)
16
+ end
17
+ end
18
+
19
+ def clear
20
+ @listeners.clear
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -1,47 +1,51 @@
1
+ require "switches/backends/memory/bus"
2
+
1
3
  module Switches
2
4
  module Backends
3
5
  class Memory
6
+ def self.bus
7
+ @bus ||= Bus.new
8
+ end
9
+
10
+ def self.storage
11
+ @storage ||= {}
12
+ end
13
+
14
+ def self.clear
15
+ bus.clear
16
+ storage.clear
17
+ end
18
+
4
19
  def initialize(uri, instance)
5
20
  @instance = instance
6
- $switches_data ||= {}
7
- $switches_listeners ||= Set.new
8
21
  end
9
22
 
10
23
  def set(item)
11
- $switches_data[key_for(item)] = item.to_json
24
+ self.class.storage[item.key] = item.to_json
12
25
  end
13
26
 
14
27
  def get(item)
15
- if data = $switches_data[key_for(item)]
16
- parse(data)
28
+ if json = self.class.storage[item.key]
29
+ JSONSerializer.deserialize(json)
17
30
  end
18
31
  end
19
32
 
20
33
  def listen
21
- $switches_listeners.add(@instance)
34
+ self.class.bus.subscribe do |message|
35
+ update = Update.load(message)
36
+ @instance.notified(update)
37
+ end
22
38
  end
23
39
 
24
40
  def notify(update)
25
- $switches_listeners.each do |listener|
26
- listener.notified(update)
27
- end
41
+ self.class.bus.publish(update.to_json)
28
42
  end
29
43
 
30
44
  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(":")
45
+ self.class.clear
39
46
  end
40
47
 
41
- def parse(json)
42
- JSON.parse(json.to_s)
43
- rescue JSON::ParserError
44
- {}
48
+ def stop
45
49
  end
46
50
  end
47
51
  end
@@ -0,0 +1,49 @@
1
+ module Switches
2
+ module Backends
3
+ class Postgres
4
+ class Connection
5
+ def initialize(uri)
6
+ @uri = URI(uri)
7
+ end
8
+
9
+ def execute(query, *args)
10
+ connection.exec(query, args)
11
+ end
12
+
13
+ def listen(channel)
14
+ connection.exec("LISTEN #{channel}")
15
+
16
+ loop do
17
+ connection.wait_for_notify do |event, pid, message|
18
+ yield message
19
+ end
20
+ end
21
+ end
22
+
23
+ def notify(channel, payload)
24
+ connection.exec("NOTIFY #{channel}, '#{payload}'")
25
+ end
26
+
27
+ def close
28
+ connection.close
29
+ end
30
+
31
+ private
32
+
33
+ def connection
34
+ @connection ||= PG.connect(connection_options)
35
+ end
36
+
37
+ def connection_options
38
+ {
39
+ user: @uri.user,
40
+ password: @uri.password,
41
+ host: @uri.host,
42
+ port: @uri.port,
43
+ dbname: @uri.path[1..-1]
44
+ }
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,38 @@
1
+ module Switches
2
+ module Backends
3
+ class Postgres
4
+ class Table
5
+ UPDATE = "UPDATE %s SET value = $1 WHERE key = $2"
6
+ INSERT = "INSERT INTO %s (key, value) values($2, $1)"
7
+ SELECT = "SELECT value FROM %s WHERE key = $1 LIMIT 1"
8
+
9
+ def initialize(name, connection)
10
+ @name = name
11
+ @connection = connection
12
+ end
13
+
14
+ def upsert(key, value)
15
+ result = @connection.execute(UPDATE % @name, value, key)
16
+
17
+ if result.cmd_tuples == 0
18
+ @connection.execute(INSERT % @name, value, key)
19
+ end
20
+ end
21
+
22
+ def find(key)
23
+ result = @connection.execute(SELECT % @name, key)
24
+
25
+ result.each do |row|
26
+ return row["value"]
27
+ end
28
+
29
+ nil
30
+ end
31
+
32
+ def clear
33
+ @connection.execute("TRUNCATE TABLE #{@name}")
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,21 @@
1
+ module Switches
2
+ module Backends
3
+ class Postgres
4
+ class Remove
5
+ def initialize(connection)
6
+ @connection = connection
7
+ end
8
+
9
+ def run
10
+ drop_table
11
+ end
12
+
13
+ private
14
+
15
+ def drop_table
16
+ @connection.execute("DROP TABLE switches")
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,26 @@
1
+ module Switches
2
+ module Backends
3
+ class Postgres
4
+ class Setup
5
+ def initialize(connection)
6
+ @connection = connection
7
+ end
8
+
9
+ def run
10
+ create_table
11
+ create_index
12
+ end
13
+
14
+ private
15
+
16
+ def create_table
17
+ @connection.execute("CREATE TABLE switches (key varchar, value text)")
18
+ end
19
+
20
+ def create_index
21
+ @connection.execute("CREATE UNIQUE INDEX switches_key ON switches (key)")
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -1,8 +1,11 @@
1
1
  require "pg"
2
+ require "switches/backends/postgres/connection"
3
+ require "switches/backends/postgres/table"
2
4
 
3
5
  module Switches
4
6
  module Backends
5
7
  class Postgres
8
+ CHANNEL = "switches"
6
9
  TABLE = "switches"
7
10
 
8
11
  def initialize(uri, instance)
@@ -11,90 +14,53 @@ module Switches
11
14
  end
12
15
 
13
16
  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
17
+ table.upsert(item.key, item.to_json)
23
18
  end
24
19
 
25
20
  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"])
21
+ if result = table.find(item.key)
22
+ JSONSerializer.deserialize(result)
32
23
  end
33
-
34
- nil
35
24
  end
36
25
 
37
26
  def listen
38
- Thread.new { subscribe }
27
+ @thread ||= Thread.new { subscribe }
39
28
  end
40
29
 
41
30
  def notify(update)
42
- connection.exec("NOTIFY #{TABLE}, '#{update.to_json}'")
31
+ connection.notify(CHANNEL, update.to_json)
43
32
  end
44
33
 
45
34
  def clear
46
- connection.exec("TRUNCATE TABLE #{TABLE}")
35
+ table.clear
36
+ end
37
+
38
+ def stop
39
+ listen.kill
40
+ listener.close
41
+ connection.close
47
42
  end
48
43
 
49
44
  private
50
45
 
51
46
  def listener
52
- @listener ||= connect
47
+ @listener ||= Connection.new(@uri)
53
48
  end
54
49
 
55
50
  def connection
56
- @connection ||= connect
57
- end
58
-
59
- def connect
60
- PG.connect(connection_options)
51
+ @connection ||= Connection.new(@uri)
61
52
  end
62
53
 
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
- }
54
+ def table
55
+ @table ||= Table.new(TABLE, connection)
71
56
  end
72
57
 
73
58
  def subscribe
74
- loop do
75
- listener.exec("LISTEN #{TABLE}")
76
- listener.wait_for_notify do |event, pid, message|
77
- process(message)
78
- end
59
+ listener.listen(CHANNEL) do |message|
60
+ update = Update.load(message)
61
+ @instance.notified(update)
79
62
  end
80
63
  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
64
  end
99
65
  end
100
66
  end
@@ -12,17 +12,17 @@ module Switches
12
12
  end
13
13
 
14
14
  def set(item)
15
- connection.set(key_for(item), item.to_json)
15
+ connection.set(item.key, item.to_json)
16
16
  end
17
17
 
18
18
  def get(item)
19
- if data = connection.get(key_for(item))
20
- parse(data)
19
+ if json = connection.get(item.key)
20
+ JSONSerializer.deserialize(json)
21
21
  end
22
22
  end
23
23
 
24
24
  def listen
25
- Thread.new { subscribe }
25
+ @thread ||= Thread.new { subscribe }
26
26
  end
27
27
 
28
28
  def notify(update)
@@ -33,6 +33,12 @@ module Switches
33
33
  connection.flushdb
34
34
  end
35
35
 
36
+ def stop
37
+ listen.kill
38
+ connection.quit
39
+ listener.quit
40
+ end
41
+
36
42
  private
37
43
 
38
44
  def listener
@@ -49,26 +55,12 @@ module Switches
49
55
 
50
56
  def subscribe
51
57
  listener.subscribe(CHANNEL) do |on|
52
- on.message { |_, message| process(message) }
58
+ on.message do |_, message|
59
+ update = Update.load(message)
60
+ @instance.notified(update)
61
+ end
53
62
  end
54
63
  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
64
  end
73
65
  end
74
66
  end
@@ -1,7 +1,13 @@
1
1
  module Switches
2
2
  class Cohort
3
+ include JSONSerializer
4
+
3
5
  attr_reader :name
4
6
 
7
+ def self.collection(instance)
8
+ Collection.new(self, instance)
9
+ end
10
+
5
11
  def initialize(name, instance)
6
12
  @name = name
7
13
  @instance = instance
@@ -9,24 +15,24 @@ module Switches
9
15
  end
10
16
 
11
17
  def reload
12
- if data = @instance.get(self)
13
- @members = data["members"].to_set
18
+ if attributes = @instance.get(self)
19
+ @members = attributes["members"].to_set
14
20
  end
15
21
 
16
22
  self
17
23
  end
18
24
 
19
25
  def include?(identifier)
20
- @members.include?(identifier)
26
+ @members.include?(identifier.to_s)
21
27
  end
22
28
 
23
- def add(member)
24
- @members.add(member.to_s)
29
+ def add(identifier)
30
+ @members.add(identifier.to_s)
25
31
  updated
26
32
  end
27
33
 
28
- def remove(member)
29
- @members.delete(member.to_s)
34
+ def remove(identifier)
35
+ @members.delete(identifier.to_s)
30
36
  updated
31
37
  end
32
38
 
@@ -37,11 +43,11 @@ module Switches
37
43
  output += ">"
38
44
  end
39
45
 
40
- def to_json
46
+ def as_json
41
47
  {
42
48
  name: name,
43
49
  members: members
44
- }.to_json
50
+ }
45
51
  end
46
52
 
47
53
  def members
@@ -52,6 +58,10 @@ module Switches
52
58
  "cohort"
53
59
  end
54
60
 
61
+ def key
62
+ [type, name].join(":")
63
+ end
64
+
55
65
  private
56
66
 
57
67
  def updated
@@ -0,0 +1,31 @@
1
+ module Switches
2
+ class Collection
3
+ def initialize(klass, instance)
4
+ @collection = Hash.new do |collection, name|
5
+ name = name.to_sym
6
+ item = klass.new(name, instance)
7
+
8
+ item.reload
9
+ collection[name] = item
10
+ end
11
+ end
12
+
13
+ def [](name)
14
+ @collection[name.to_sym]
15
+ end
16
+
17
+ def reload(name)
18
+ name = name.to_sym
19
+
20
+ if include?(name)
21
+ @collection[name].reload
22
+ else
23
+ @collection[name]
24
+ end
25
+ end
26
+
27
+ def include?(name)
28
+ @collection.keys.include?(name.to_sym)
29
+ end
30
+ end
31
+ end
@@ -1,7 +1,13 @@
1
1
  module Switches
2
2
  class Feature
3
+ include JSONSerializer
4
+
3
5
  attr_reader :name, :percentage
4
6
 
7
+ def self.collection(instance)
8
+ Collection.new(self, instance)
9
+ end
10
+
5
11
  def initialize(name, instance)
6
12
  @name = name
7
13
  @instance = instance
@@ -10,9 +16,9 @@ module Switches
10
16
  end
11
17
 
12
18
  def reload
13
- if data = @instance.get(self)
14
- @percentage = Percentage(data["percentage"])
15
- @cohorts = data["cohorts"].to_set
19
+ if attributes = @instance.get(self)
20
+ @percentage = Percentage(attributes["percentage"])
21
+ @cohorts = attributes["cohorts"].to_set
16
22
  end
17
23
 
18
24
  self
@@ -44,12 +50,12 @@ module Switches
44
50
  output += ">"
45
51
  end
46
52
 
47
- def to_json
53
+ def as_json
48
54
  {
49
55
  name: name,
50
56
  percentage: percentage.to_i,
51
57
  cohorts: @cohorts.to_a
52
- }.to_json
58
+ }
53
59
  end
54
60
 
55
61
  def cohorts
@@ -66,6 +72,10 @@ module Switches
66
72
  "feature"
67
73
  end
68
74
 
75
+ def key
76
+ [type, name].join(":")
77
+ end
78
+
69
79
  private
70
80
 
71
81
  def in_cohort?(identifier)
@@ -16,6 +16,10 @@ module Switches
16
16
  self
17
17
  end
18
18
 
19
+ def stop
20
+ backend.stop
21
+ end
22
+
19
23
  def feature(name)
20
24
  synchronize do
21
25
  features[name]
@@ -37,27 +41,13 @@ module Switches
37
41
  end
38
42
 
39
43
  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
44
+ update = Update.build(item, node_id)
45
+ backend.notify(update)
47
46
  end
48
47
 
49
48
  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
49
+ unless update.from?(node_id)
50
+ collections[update.type].reload(update.name)
61
51
  end
62
52
  end
63
53
 
@@ -76,11 +66,18 @@ module Switches
76
66
  end
77
67
 
78
68
  def features
79
- @features ||= Features.new(self)
69
+ @features ||= Feature.collection(self)
80
70
  end
81
71
 
82
72
  def cohorts
83
- @cohorts ||= Cohorts.new(self)
73
+ @cohorts ||= Cohort.collection(self)
74
+ end
75
+
76
+ def collections
77
+ {
78
+ "feature" => features,
79
+ "cohort" => cohorts
80
+ }
84
81
  end
85
82
  end
86
83
  end
@@ -0,0 +1,25 @@
1
+ module Switches
2
+ module JSONSerializer
3
+ ParserError = Class.new(MultiJson::LoadError)
4
+
5
+ def as_json
6
+ raise NotImplementedError
7
+ end
8
+
9
+ def to_json
10
+ serialize(as_json)
11
+ end
12
+
13
+ module_function
14
+
15
+ def serialize(object)
16
+ MultiJson.dump(object)
17
+ end
18
+
19
+ def deserialize(json)
20
+ MultiJson.load(json)
21
+ rescue Exception => e
22
+ raise ParserError
23
+ end
24
+ end
25
+ end
@@ -9,7 +9,7 @@ module Switches
9
9
  protected :value
10
10
 
11
11
  def initialize(value)
12
- @value = clip(value)
12
+ @value = clip(value.to_i)
13
13
  end
14
14
 
15
15
  def <=>(other)
@@ -17,7 +17,7 @@ module Switches
17
17
  end
18
18
 
19
19
  def include?(identifier)
20
- Percentage(Zlib.crc32(identifier) % UPPER) < self
20
+ Percentage(Zlib.crc32(identifier.to_s) % UPPER) < self
21
21
  end
22
22
 
23
23
  def to_i
@@ -0,0 +1,27 @@
1
+ require "switches"
2
+ require "switches/backends/postgres/tasks/setup"
3
+ require "switches/backends/postgres/tasks/remove"
4
+
5
+ namespace :switches do
6
+ namespace :postgres do
7
+ task :database do
8
+ database_url = ENV["DATABASE_URL"]
9
+
10
+ unless database_url
11
+ abort "DATABASE_URL required. (e.g. postgres://user:password@hostname:port/dbname)"
12
+ end
13
+
14
+ @connection = Switches::Backends::Postgres::Connection.new(database_url)
15
+ end
16
+
17
+ desc "Setup Switches PostgreSQL table"
18
+ task setup: :database do
19
+ Switches::Backends::Postgres::Setup.new(@connection).run
20
+ end
21
+
22
+ desc "Remove Switches PostgreSQL table"
23
+ task remove: :database do
24
+ Switches::Backends::Postgres::Remove.new(@connection).run
25
+ end
26
+ end
27
+ end
@@ -1,7 +1,22 @@
1
1
  module Switches
2
2
  class Update
3
+ include JSONSerializer
4
+
3
5
  attr_accessor :type, :name, :node_id
4
6
 
7
+ def self.build(item, node_id)
8
+ new.tap do |update|
9
+ update.type = item.type
10
+ update.name = item.name
11
+ update.node_id = node_id
12
+ end
13
+ end
14
+
15
+ def self.load(json)
16
+ attributes = JSONSerializer.deserialize(json)
17
+ new(attributes)
18
+ end
19
+
5
20
  def initialize(attributes = {})
6
21
  @type = attributes["type"]
7
22
  @name = attributes["name"]
@@ -12,12 +27,12 @@ module Switches
12
27
  @node_id == node_id
13
28
  end
14
29
 
15
- def to_json
30
+ def as_json
16
31
  {
17
- "type" => type,
18
- "name" => name,
19
- "node_id" => node_id
20
- }.to_json
32
+ type: type,
33
+ name: name,
34
+ node_id: node_id
35
+ }
21
36
  end
22
37
  end
23
38
  end
data/lib/switches.rb CHANGED
@@ -4,19 +4,22 @@ require "monitor"
4
4
  require "json"
5
5
  require "set"
6
6
  require "zlib"
7
+ require "uri"
7
8
 
9
+ require "multi_json"
10
+
11
+ require "switches/json_serializer"
8
12
  require "switches/configuration"
13
+ require "switches/collection"
9
14
  require "switches/instance"
10
- require "switches/cohorts"
11
15
  require "switches/cohort"
12
- require "switches/features"
13
16
  require "switches/feature"
14
17
  require "switches/percentage"
15
18
  require "switches/update"
19
+ require "switches/backend"
16
20
  require "switches/backends/redis"
17
21
  require "switches/backends/memory"
18
22
  require "switches/backends/postgres"
19
- require "switches/backend"
20
23
 
21
24
  Thread.abort_on_exception = true
22
25
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: feature_switches
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - John Pignata
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2013-04-11 00:00:00.000000000 Z
11
+ date: 2013-04-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rspec
@@ -33,21 +33,29 @@ extensions: []
33
33
  extra_rdoc_files: []
34
34
  files:
35
35
  - lib/switches/backend.rb
36
+ - lib/switches/backends/memory/bus.rb
36
37
  - lib/switches/backends/memory.rb
38
+ - lib/switches/backends/postgres/connection.rb
39
+ - lib/switches/backends/postgres/table.rb
40
+ - lib/switches/backends/postgres/tasks/remove.rb
41
+ - lib/switches/backends/postgres/tasks/setup.rb
37
42
  - lib/switches/backends/postgres.rb
38
43
  - lib/switches/backends/redis.rb
39
44
  - lib/switches/cohort.rb
40
- - lib/switches/cohorts.rb
45
+ - lib/switches/collection.rb
41
46
  - lib/switches/configuration.rb
42
47
  - lib/switches/feature.rb
43
- - lib/switches/features.rb
44
48
  - lib/switches/instance.rb
49
+ - lib/switches/json_serializer.rb
45
50
  - lib/switches/percentage.rb
51
+ - lib/switches/tasks.rb
46
52
  - lib/switches/update.rb
47
53
  - lib/switches.rb
48
54
  - README.md
55
+ - LICENSE
49
56
  homepage: http://github.com/jpignata/switches
50
- licenses: []
57
+ licenses:
58
+ - MIT
51
59
  metadata: {}
52
60
  post_install_message:
53
61
  rdoc_options: []
@@ -1,14 +0,0 @@
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
@@ -1,14 +0,0 @@
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