feature_switches 0.1.0 → 0.2.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 +4 -4
- data/LICENSE +19 -0
- data/README.md +73 -41
- data/lib/switches/backend.rb +0 -2
- data/lib/switches/backends/memory/bus.rb +25 -0
- data/lib/switches/backends/memory.rb +25 -21
- data/lib/switches/backends/postgres/connection.rb +49 -0
- data/lib/switches/backends/postgres/table.rb +38 -0
- data/lib/switches/backends/postgres/tasks/remove.rb +21 -0
- data/lib/switches/backends/postgres/tasks/setup.rb +26 -0
- data/lib/switches/backends/postgres.rb +22 -56
- data/lib/switches/backends/redis.rb +14 -22
- data/lib/switches/cohort.rb +19 -9
- data/lib/switches/collection.rb +31 -0
- data/lib/switches/feature.rb +15 -5
- data/lib/switches/instance.rb +17 -20
- data/lib/switches/json_serializer.rb +25 -0
- data/lib/switches/percentage.rb +2 -2
- data/lib/switches/tasks.rb +27 -0
- data/lib/switches/update.rb +20 -5
- data/lib/switches.rb +6 -3
- metadata +13 -5
- data/lib/switches/cohorts.rb +0 -14
- data/lib/switches/features.rb +0 -14
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: ef07bad4705d60fcc441c0a2964525369464ca8a
|
4
|
+
data.tar.gz: 0613e9277ad7cbcc269b64847ad923050402c0d6
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
|
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
|
21
|
-
that expires given a certain TTL can't work as a client isn't
|
22
|
-
to talking to the same instance of our application on each
|
23
|
-
feature disappearing and reappearing depending on which
|
24
|
-
|
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
|
-
|
32
|
-
|
33
|
-
|
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
|
36
|
-
|
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
|
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
|
53
|
+
received by other listening nodes they will refetch the configuration data
|
56
54
|
and update their local stores.
|
57
55
|
|
58
|
-
|
59
|
-
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
|
67
|
-
|
|
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/
|
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
|
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
|
149
|
+
# => #<Feature redesign; 0%>
|
122
150
|
```
|
151
|
+
|
152
|
+
## License
|
153
|
+
|
154
|
+
Please see LICENSE.
|
data/lib/switches/backend.rb
CHANGED
@@ -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
|
-
|
24
|
+
self.class.storage[item.key] = item.to_json
|
12
25
|
end
|
13
26
|
|
14
27
|
def get(item)
|
15
|
-
if
|
16
|
-
|
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
|
-
|
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
|
-
|
26
|
-
listener.notified(update)
|
27
|
-
end
|
41
|
+
self.class.bus.publish(update.to_json)
|
28
42
|
end
|
29
43
|
|
30
44
|
def clear
|
31
|
-
|
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
|
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
|
-
|
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 =
|
27
|
-
|
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.
|
31
|
+
connection.notify(CHANNEL, update.to_json)
|
43
32
|
end
|
44
33
|
|
45
34
|
def clear
|
46
|
-
|
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 ||=
|
47
|
+
@listener ||= Connection.new(@uri)
|
53
48
|
end
|
54
49
|
|
55
50
|
def connection
|
56
|
-
@connection ||=
|
57
|
-
end
|
58
|
-
|
59
|
-
def connect
|
60
|
-
PG.connect(connection_options)
|
51
|
+
@connection ||= Connection.new(@uri)
|
61
52
|
end
|
62
53
|
|
63
|
-
def
|
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
|
-
|
75
|
-
|
76
|
-
|
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(
|
15
|
+
connection.set(item.key, item.to_json)
|
16
16
|
end
|
17
17
|
|
18
18
|
def get(item)
|
19
|
-
if
|
20
|
-
|
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
|
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
|
data/lib/switches/cohort.rb
CHANGED
@@ -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
|
13
|
-
@members =
|
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(
|
24
|
-
@members.add(
|
29
|
+
def add(identifier)
|
30
|
+
@members.add(identifier.to_s)
|
25
31
|
updated
|
26
32
|
end
|
27
33
|
|
28
|
-
def remove(
|
29
|
-
@members.delete(
|
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
|
46
|
+
def as_json
|
41
47
|
{
|
42
48
|
name: name,
|
43
49
|
members: members
|
44
|
-
}
|
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
|
data/lib/switches/feature.rb
CHANGED
@@ -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
|
14
|
-
@percentage = Percentage(
|
15
|
-
@cohorts =
|
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
|
53
|
+
def as_json
|
48
54
|
{
|
49
55
|
name: name,
|
50
56
|
percentage: percentage.to_i,
|
51
57
|
cohorts: @cohorts.to_a
|
52
|
-
}
|
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)
|
data/lib/switches/instance.rb
CHANGED
@@ -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
|
-
|
41
|
-
|
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
|
-
|
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 ||=
|
69
|
+
@features ||= Feature.collection(self)
|
80
70
|
end
|
81
71
|
|
82
72
|
def cohorts
|
83
|
-
@cohorts ||=
|
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
|
data/lib/switches/percentage.rb
CHANGED
@@ -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
|
data/lib/switches/update.rb
CHANGED
@@ -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
|
30
|
+
def as_json
|
16
31
|
{
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
}
|
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.
|
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
|
+
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/
|
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: []
|
data/lib/switches/cohorts.rb
DELETED
data/lib/switches/features.rb
DELETED
@@ -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
|