stoplight 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,72 +1,82 @@
1
1
  # coding: utf-8
2
2
 
3
- begin
4
- require 'redis'
5
- REDIS_LOADED = true
6
- rescue LoadError
7
- REDIS_LOADED = false
8
- end
9
-
10
3
  module Stoplight
11
4
  module DataStore
12
5
  class Redis < Base
13
- def initialize(*args)
14
- fail Error::NoRedis unless REDIS_LOADED
15
-
16
- @redis = ::Redis.new(*args)
6
+ def initialize(redis)
7
+ @redis = redis
17
8
  end
18
9
 
19
10
  def names
20
- @redis.scan_each(match: "#{DataStore::KEY_PREFIX}:*:*").map do |key|
21
- key[/^#{DataStore::KEY_PREFIX}:(.+):[^:]+$/o, 1]
22
- end.uniq
11
+ @redis.hkeys(thresholds_key)
23
12
  end
24
13
 
25
- def record_failure(name, error)
26
- failure = Failure.new(error)
27
- @redis.rpush(failure_key(name), failure.to_json)
28
- # TODO: Trim failures (think ring buffer). Probably in a multi block.
14
+ def purge
15
+ names
16
+ .select { |l| failures(l).empty? }
17
+ .each { |l| delete(l) }
29
18
  end
30
19
 
31
- def clear_failures(name)
32
- @redis.del(failure_key(name))
20
+ def delete(name)
21
+ @redis.pipelined do
22
+ clear_attempts(name)
23
+ clear_failures(name)
24
+ @redis.hdel(states_key, name)
25
+ @redis.hdel(thresholds_key, name)
26
+ end
33
27
  end
34
28
 
35
- def failures(name)
36
- @redis.lrange(failure_key(name), 0, -1)
29
+ # @group Attempts
30
+
31
+ def attempts(name)
32
+ @redis.hget(attempts_key, name).to_i
37
33
  end
38
34
 
39
- def threshold(name)
40
- value = @redis.hget(settings_key(name), 'threshold')
41
- Integer(value) if value
35
+ def record_attempt(name)
36
+ @redis.hincrby(attempts_key, name, 1)
42
37
  end
43
38
 
44
- def set_threshold(name, threshold)
45
- @redis.hset(settings_key(name), 'threshold', threshold)
46
- threshold
39
+ def clear_attempts(name)
40
+ @redis.hdel(attempts_key, name)
47
41
  end
48
42
 
49
- def record_attempt(name)
50
- @redis.incr(attempt_key(name))
43
+ # @group Failures
44
+
45
+ def failures(name)
46
+ @redis.lrange(failures_key(name), 0, -1)
51
47
  end
52
48
 
53
- def clear_attempts(name)
54
- @redis.del(attempt_key(name))
49
+ def record_failure(name, error)
50
+ @redis.rpush(failures_key(name), Failure.new(error).to_json)
55
51
  end
56
52
 
57
- def attempts(name)
58
- @redis.get(attempt_key(name)).to_i
53
+ def clear_failures(name)
54
+ @redis.del(failures_key(name))
59
55
  end
60
56
 
57
+ # @group State
58
+
61
59
  def state(name)
62
- @redis.hget(settings_key(name), 'state') || DataStore::STATE_UNLOCKED
60
+ @redis.hget(states_key, name) || STATE_UNLOCKED
63
61
  end
64
62
 
65
63
  def set_state(name, state)
66
64
  validate_state!(state)
67
- @redis.hset(settings_key(name), 'state', state)
65
+ @redis.hset(states_key, name, state)
68
66
  state
69
67
  end
68
+
69
+ # @group Threshold
70
+
71
+ def threshold(name)
72
+ value = @redis.hget(thresholds_key, name)
73
+ Integer(value) if value
74
+ end
75
+
76
+ def set_threshold(name, threshold)
77
+ @redis.hset(thresholds_key, name, threshold)
78
+ threshold
79
+ end
70
80
  end
71
81
  end
72
82
  end
@@ -5,8 +5,6 @@ module Stoplight
5
5
  # @return [Class]
6
6
  Base = Class.new(StandardError)
7
7
  # @return [Class]
8
- NoFallback = Class.new(Base)
9
- # @return [Class]
10
- NoRedis = Class.new(Base)
8
+ RedLight = Class.new(Base)
11
9
  end
12
10
  end
@@ -2,9 +2,6 @@
2
2
 
3
3
  module Stoplight
4
4
  class Light
5
- # @return [Integer]
6
- DEFAULT_THRESHOLD = 3
7
-
8
5
  # @return [Array<Exception>]
9
6
  attr_reader :allowed_errors
10
7
 
@@ -23,7 +20,7 @@ module Stoplight
23
20
  end
24
21
 
25
22
  # @return [Object]
26
- # @raise [Error::NoFallback]
23
+ # @raise [Error::RedLight]
27
24
  # @see #fallback
28
25
  # @see #green?
29
26
  def run
@@ -32,6 +29,11 @@ module Stoplight
32
29
  if green?
33
30
  run_code
34
31
  else
32
+ if Stoplight.attempts(name).zero?
33
+ message = "Switching #{name} stoplight from green to red."
34
+ Stoplight.notifiers.each { |notifier| notifier.notify(message) }
35
+ end
36
+
35
37
  run_fallback
36
38
  end
37
39
  end
@@ -55,17 +57,17 @@ module Stoplight
55
57
  # @param threshold [Integer]
56
58
  # @return [self]
57
59
  def with_threshold(threshold)
58
- Stoplight.data_store.set_threshold(name, threshold.to_i)
60
+ Stoplight.set_threshold(name, threshold.to_i)
59
61
  self
60
62
  end
61
63
 
62
64
  # Attribute readers
63
65
 
64
66
  # @return [Object]
65
- # @raise [Error::NoFallback]
67
+ # @raise [Error::RedLight]
66
68
  def fallback
67
69
  return @fallback if defined?(@fallback)
68
- fail Error::NoFallback
70
+ fail Error::RedLight
69
71
  end
70
72
 
71
73
  # @return (see Stoplight.green?)
@@ -91,25 +93,25 @@ module Stoplight
91
93
 
92
94
  def run_code
93
95
  result = code.call
94
- Stoplight.data_store.clear_failures(name)
96
+ Stoplight.clear_failures(name)
95
97
  result
96
98
  rescue => error
97
99
  if error_allowed?(error)
98
- Stoplight.data_store.clear_failures(name)
100
+ Stoplight.clear_failures(name)
99
101
  else
100
- Stoplight.data_store.record_failure(name, error)
102
+ Stoplight.record_failure(name, error)
101
103
  end
102
104
 
103
105
  raise
104
106
  end
105
107
 
106
108
  def run_fallback
107
- Stoplight.data_store.record_attempt(name)
109
+ Stoplight.record_attempt(name)
108
110
  fallback.call
109
111
  end
110
112
 
111
113
  def sync_settings
112
- Stoplight.data_store.set_threshold(name, threshold)
114
+ Stoplight.set_threshold(name, threshold)
113
115
  end
114
116
  end
115
117
  end
@@ -0,0 +1,9 @@
1
+ # coding: utf-8
2
+
3
+ module Stoplight
4
+ module Mixin
5
+ def stoplight(name, &block)
6
+ Stoplight::Light.new(name, &block).run
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,6 @@
1
+ # coding: utf-8
2
+
3
+ module Stoplight
4
+ module Notifier
5
+ end
6
+ end
@@ -0,0 +1,11 @@
1
+ # coding: utf-8
2
+
3
+ module Stoplight
4
+ module Notifier
5
+ class Base
6
+ def notify(_message)
7
+ fail NotImplementedError
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,27 @@
1
+ # coding: utf-8
2
+
3
+ module Stoplight
4
+ module Notifier
5
+ # @note hipchat ~> 1.3.0
6
+ class HipChat < Base
7
+ # @param client [HipChat::Client]
8
+ # @param room [String]
9
+ # @param options [Hash]
10
+ def initialize(client, room, options = {})
11
+ @client = client
12
+ @room = room
13
+ @options = default_options.merge(options)
14
+ end
15
+
16
+ def notify(message)
17
+ @client[@room].send('Stoplight', "@all #{message}", @options)
18
+ end
19
+
20
+ private
21
+
22
+ def default_options
23
+ { color: 'red', notify: true }
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,11 @@
1
+ # coding: utf-8
2
+
3
+ module Stoplight
4
+ module Notifier
5
+ class StandardError < Base
6
+ def notify(message)
7
+ warn(message)
8
+ end
9
+ end
10
+ end
11
+ end
data/spec/spec_helper.rb CHANGED
@@ -4,3 +4,7 @@ require 'coveralls'
4
4
  Coveralls.wear!
5
5
 
6
6
  require 'stoplight'
7
+
8
+ Dir.glob(File.join('.', 'spec', 'support', '**', '*.rb')).each do |filename|
9
+ require filename
10
+ end
@@ -9,8 +9,10 @@ describe Stoplight::DataStore::Base do
9
9
  attempts
10
10
  clear_attempts
11
11
  clear_failures
12
+ delete
12
13
  failures
13
14
  names
15
+ purge
14
16
  record_attempt
15
17
  record_failure
16
18
  set_state
@@ -3,187 +3,7 @@
3
3
  require 'spec_helper'
4
4
 
5
5
  describe Stoplight::DataStore::Memory do
6
- let(:error) { error_klass.new }
7
- let(:error_klass) { Class.new(StandardError) }
8
- let(:name) { SecureRandom.hex }
9
- let(:state) { Stoplight::DataStore::STATE_LOCKED_GREEN }
10
- let(:threshold) { rand(10) }
11
-
12
6
  subject(:data_store) { described_class.new }
13
7
 
14
- describe '#attempts' do
15
- subject(:result) { data_store.attempts(name) }
16
-
17
- it 'is zero' do
18
- expect(result).to eql(0)
19
- end
20
-
21
- context 'with an attempt' do
22
- before { data_store.record_attempt(name) }
23
-
24
- it 'is one' do
25
- expect(result).to eql(1)
26
- end
27
- end
28
- end
29
-
30
- describe '#clear_attempts' do
31
- subject(:result) { data_store.clear_attempts(name) }
32
-
33
- it 'returns nil' do
34
- expect(result).to be_nil
35
- end
36
-
37
- context 'with an attempt' do
38
- before { data_store.record_attempt(name) }
39
-
40
- it 'returns one' do
41
- expect(result).to eql(1)
42
- end
43
-
44
- it 'clears attempts' do
45
- result
46
- expect(data_store.attempts(name)).to eql(0)
47
- end
48
- end
49
- end
50
-
51
- describe '#clear_failures' do
52
- subject(:result) { data_store.clear_failures(name) }
53
-
54
- it 'returns nil' do
55
- expect(result).to be nil
56
- end
57
-
58
- context 'with a failure' do
59
- before { data_store.record_failure(name, error) }
60
-
61
- it 'returns the failures' do
62
- failures = data_store.failures(name)
63
- expect(result).to eql(failures)
64
- end
65
-
66
- it 'clears the failures' do
67
- result
68
- expect(data_store.failures(name)).to be_empty
69
- end
70
- end
71
- end
72
-
73
- describe '#threshold' do
74
- subject(:result) { data_store.threshold(name) }
75
-
76
- it 'is nil' do
77
- expect(result).to be nil
78
- end
79
-
80
- context 'with a threshold' do
81
- before { data_store.set_threshold(name, threshold) }
82
-
83
- it 'returns the threshold' do
84
- expect(result).to eql(threshold)
85
- end
86
- end
87
- end
88
-
89
- describe '#failures' do
90
- subject(:result) { data_store.failures(name) }
91
-
92
- it 'is an array' do
93
- expect(result).to be_an(Array)
94
- end
95
-
96
- it 'is empty' do
97
- expect(result).to be_empty
98
- end
99
-
100
- context 'with a failure' do
101
- before { data_store.record_failure(name, error) }
102
-
103
- it 'returns the failures' do
104
- expect(result.size).to eql(1)
105
- end
106
- end
107
- end
108
-
109
- describe '#names' do
110
- subject(:result) { data_store.names }
111
-
112
- it 'is an array' do
113
- expect(result).to be_an(Array)
114
- end
115
-
116
- it 'is empty' do
117
- expect(result).to be_empty
118
- end
119
-
120
- context 'with a name' do
121
- before { data_store.settings(name) }
122
-
123
- it 'includes the name' do
124
- expect(result).to include(name)
125
- end
126
- end
127
- end
128
-
129
- describe '#record_attempt' do
130
- subject(:result) { data_store.record_attempt(name) }
131
-
132
- it 'records the attempt' do
133
- result
134
- expect(data_store.attempts(name)).to eql(1)
135
- end
136
- end
137
-
138
- describe '#record_failure' do
139
- subject(:result) { data_store.record_failure(name, error) }
140
-
141
- it 'returns the failures' do
142
- expect(result).to eql(data_store.failures(name))
143
- end
144
-
145
- it 'logs the failure' do
146
- expect(result.size).to eql(1)
147
- end
148
- end
149
-
150
- describe '#set_threshold' do
151
- subject(:result) { data_store.set_threshold(name, threshold) }
152
-
153
- it 'returns the threshold' do
154
- expect(result).to eql(threshold)
155
- end
156
- end
157
-
158
- describe '#set_state' do
159
- subject(:result) { data_store.set_state(name, state) }
160
-
161
- it 'returns the state' do
162
- expect(result).to eql(state)
163
- end
164
-
165
- context 'with an invalid state' do
166
- let(:state) { SecureRandom.hex }
167
-
168
- it 'raises an error' do
169
- expect { result }.to raise_error(ArgumentError)
170
- end
171
- end
172
- end
173
-
174
- describe '#state' do
175
- subject(:result) { data_store.state(name) }
176
-
177
- it 'returns the default state' do
178
- expect(result).to eql(Stoplight::DataStore::STATE_UNLOCKED)
179
- end
180
-
181
- context 'with a custom state' do
182
- before { data_store.set_state(name, state) }
183
-
184
- it 'returns the state' do
185
- expect(result).to eql(state)
186
- end
187
- end
188
- end
8
+ it_behaves_like 'a data store'
189
9
  end