etcd-rb 1.0.0.pre0 → 1.0.0.pre1

Sign up to get free protection for your applications and to get access to all the features.
data/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # Ruby CQL3 driver
1
+ # Ruby [etcd](https://github.com/coreos/etcd) driver
2
2
 
3
3
  [![Build Status](https://travis-ci.org/iconara/etcd-rb.png?branch=master)](https://travis-ci.org/iconara/etcd-rb)
4
4
  [![Coverage Status](https://coveralls.io/repos/iconara/etcd-rb/badge.png?branch=master)](https://coveralls.io/r/iconara/etcd-rb)
@@ -7,6 +7,8 @@
7
7
 
8
8
  A modern Ruby, compatible with 1.9.3 or later. Continously tested with MRI 1.9.3, 2.0.0 and JRuby 1.7.x.
9
9
 
10
+ An etcd cluster. _Currently incompatible with the most recent versions of etcd because they return the wrong URI for the leader._
11
+
10
12
  # Installation
11
13
 
12
14
  gem install etcd-rb --prerelease
@@ -21,7 +23,25 @@ client.set('/foo', 'bar')
21
23
  client.get('/foo')
22
24
  ```
23
25
 
24
- See the full [API documentation](http://rubydoc.info/gems/etcd-rb/frames) for more.
26
+ See the full [API documentation](http://rubydoc.info/gems/etcd-rb/frames) for more. All core features are supported, including test-and-set, TTL, watches -- as well as a few convenience features like continuous watching.
27
+
28
+ # Features
29
+
30
+ ## Continuous watches: observers
31
+
32
+ Most of the time when you use watches with etcd you want to immediately re-watch the key when you get a change notification. The `Client#observe` method handles this for you, including re-watching with the last seen index, so that you don't miss any updates.
33
+
34
+ ## Automatic leader detection
35
+
36
+ You can point the client to any node in the etcd cluster, it will ask that node for the current leader and direct all subsequent requests directly to the leader to avoid unnecessary redirects. When the leader changes, detected by a redirect, the new leader will be registered and used instead of the previous.
37
+
38
+ ## Automacic failover & retry
39
+
40
+ When connecting for the first time, and when the leader changes, the list of nodes in the cluster is cached. Should the node that the client is talking to become unreachable, the client will attempt to connect to the next known node, until it finds one that responds. The first node to respond will be asked for the current leader, which will then be used for subsequent request.
41
+
42
+ This is handled completely transparently to you.
43
+
44
+ Watches are a special case, since they use long polling, they will break when the leader goes down. Observers will attempt to reestablish their watches with the new leader.
25
45
 
26
46
  # Changelog & versioning
27
47
 
data/lib/etcd/client.rb CHANGED
@@ -59,13 +59,35 @@ module Etcd
59
59
  class Client
60
60
  # Creates a new `etcd` client.
61
61
  #
62
+ # You should call {#connect} on the client to properly initialize it. The
63
+ # creation of the client and connection is divided into two parts to avoid
64
+ # doing network connections in the object initialization code. Many times
65
+ # you want to defer things with side-effect until the whole object graph
66
+ # has been created. {#connect} returns self so you can just chain it after
67
+ # the call to {.new}, e.g. `Client.new.connect`.
68
+ #
69
+ # You can specify a seed node to connect to using the `:uri` option (which
70
+ # defaults to 127.0.0.1:4001), but once connected the client will prefer to
71
+ # talk to the master in order to avoid unnecessary HTTP requests, and to
72
+ # make sure that get operations find the most recent value.
73
+ #
62
74
  # @param [Hash] options
63
- # @option options [String] :host ('127.0.0.1') The etcd host to connect to
64
- # @option options [String] :port (4001) The port to connect to
75
+ # @option options [String] :uri ('http://127.0.0.1:4001') The etcd host and
76
+ # port to connect to
65
77
  def initialize(options={})
66
- @host = options[:host] || '127.0.0.1'
67
- @port = options[:port] || 4001
78
+ @seed_uri = options[:uri] || 'http://127.0.0.1:4001'
79
+ @protocol_version = 'v1'
68
80
  @http_client = HTTPClient.new(agent_name: "etcd-rb/#{VERSION}")
81
+ @http_client.redirect_uri_callback = method(:handle_redirected)
82
+ end
83
+
84
+ def connect
85
+ change_uris(@seed_uri)
86
+ cache_machines
87
+ change_uris(@machines_cache.first)
88
+ self
89
+ rescue AllNodesDownError => e
90
+ raise ConnectionError, e.message, e.backtrace
69
91
  end
70
92
 
71
93
  # Sets the value of a key.
@@ -84,7 +106,7 @@ module Etcd
84
106
  if ttl = options[:ttl]
85
107
  body[:ttl] = ttl
86
108
  end
87
- response = @http_client.post(uri(key), body)
109
+ response = request(:post, uri(key), body: body)
88
110
  data = MultiJson.load(response.body)
89
111
  data[S_PREV_VALUE]
90
112
  end
@@ -110,7 +132,7 @@ module Etcd
110
132
  if ttl = options[:ttl]
111
133
  body[:ttl] = ttl
112
134
  end
113
- response = @http_client.post(uri(key), body)
135
+ response = request(:post, uri(key), body: body)
114
136
  response.status == 200
115
137
  end
116
138
 
@@ -123,7 +145,7 @@ module Etcd
123
145
  # @return [String, Hash] the value for the key, or a hash of keys and values
124
146
  # when the key is a prefix.
125
147
  def get(key)
126
- response = @http_client.get(uri(key))
148
+ response = request(:get, uri(key))
127
149
  if response.status == 200
128
150
  data = MultiJson.load(response.body)
129
151
  if data.is_a?(Array)
@@ -154,7 +176,7 @@ module Etcd
154
176
  # @return [Hash] a with info about the key, the exact contents depend on
155
177
  # what kind of key it is.
156
178
  def info(key)
157
- response = @http_client.get(uri(key))
179
+ response = request(:get, uri(key))
158
180
  if response.status == 200
159
181
  data = MultiJson.load(response.body)
160
182
  if data.is_a?(Array)
@@ -180,7 +202,7 @@ module Etcd
180
202
  # @param key [String] the key to remove
181
203
  # @return [String] the previous value, if any
182
204
  def delete(key)
183
- response = @http_client.delete(uri(key))
205
+ response = request(:delete, uri(key))
184
206
  if response.status == 200
185
207
  data = MultiJson.load(response.body)
186
208
  data[S_PREV_VALUE]
@@ -228,7 +250,7 @@ module Etcd
228
250
  if index = options[:index]
229
251
  parameters[:index] = index
230
252
  end
231
- response = @http_client.get(uri(prefix, S_WATCH), parameters)
253
+ response = request(:get, uri(prefix, S_WATCH), query: parameters)
232
254
  data = MultiJson.load(response.body)
233
255
  info = extract_info(data)
234
256
  yield info[:value], info[:key], info
@@ -268,6 +290,16 @@ module Etcd
268
290
  Observer.new(self, prefix, handler).tap(&:run)
269
291
  end
270
292
 
293
+ # Returns a list of URIs for the machines in the `etcd` cluster.
294
+ #
295
+ # The first URI is for the leader.
296
+ #
297
+ # @return [Array<String>] the URIs of the machines in the cluster
298
+ def machines
299
+ response = request(:get, @machines_uri)
300
+ response.body.split(MACHINES_SEPARATOR_RE)
301
+ end
302
+
271
303
  private
272
304
 
273
305
  S_KEY = 'key'.freeze
@@ -281,12 +313,23 @@ module Etcd
281
313
  S_PREV_VALUE = 'prevValue'.freeze
282
314
  S_ACTION = 'action'.freeze
283
315
  S_WATCH = 'watch'.freeze
316
+ S_LOCATION = 'location'.freeze
284
317
 
285
318
  S_SLASH = '/'.freeze
319
+ MACHINES_SEPARATOR_RE = /,\s*/
286
320
 
287
321
  def uri(key, action=S_KEYS)
288
322
  key = "/#{key}" unless key.start_with?(S_SLASH)
289
- "http://#{@host}:#{@port}/v1/#{action}#{key}"
323
+ "#{@base_uri}/#{action}#{key}"
324
+ end
325
+
326
+ def request(method, uri, args={})
327
+ @http_client.request(method, uri, args.merge(follow_redirect: true))
328
+ rescue HTTPClient::TimeoutError => e
329
+ old_base_uri = @base_uri
330
+ handle_leader_down
331
+ uri.sub!(old_base_uri, @base_uri)
332
+ retry
290
333
  end
291
334
 
292
335
  def extract_info(data)
@@ -308,6 +351,32 @@ module Etcd
308
351
  info
309
352
  end
310
353
 
354
+ def handle_redirected(uri, response)
355
+ location = URI.parse(response.header[S_LOCATION][0])
356
+ change_uris("#{location.scheme}://#{location.host}:#{location.port}")
357
+ cache_machines
358
+ @http_client.default_redirect_uri_callback(uri, response)
359
+ end
360
+
361
+ def handle_leader_down
362
+ if @machines_cache && @machines_cache.any?
363
+ @machines_cache.reject! { |m| @base_uri.include?(m) }
364
+ change_uris(@machines_cache.shift)
365
+ else
366
+ raise AllNodesDownError, 'All known nodes are down'
367
+ end
368
+ end
369
+
370
+ def cache_machines
371
+ @machines_cache = machines
372
+ end
373
+
374
+ def change_uris(leader_uri, options={})
375
+ @base_uri = "#{leader_uri}/#{@protocol_version}"
376
+ @leader_uri = "#{@base_uri}/leader"
377
+ @machines_uri = "#{@base_uri}/machines"
378
+ end
379
+
311
380
  # @private
312
381
  class Observer
313
382
  def initialize(client, prefix, handler)
data/lib/etcd/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # encoding: utf-8
2
2
 
3
3
  module Etcd
4
- VERSION = '1.0.0.pre0'.freeze
4
+ VERSION = '1.0.0.pre1'.freeze
5
5
  end
data/lib/etcd.rb CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  module Etcd
4
4
  EtcdError = Class.new(StandardError)
5
+ ConnectionError = Class.new(EtcdError)
6
+ AllNodesDownError = Class.new(EtcdError)
5
7
  end
6
8
 
7
9
  require 'etcd/version'
@@ -6,7 +6,7 @@ require 'spec_helper'
6
6
  module Etcd
7
7
  describe Client do
8
8
  let :client do
9
- described_class.new(host: host, port: port)
9
+ described_class.new(uri: "http://#{host}:#{port}").connect
10
10
  end
11
11
 
12
12
  let :host do
@@ -14,13 +14,50 @@ module Etcd
14
14
  end
15
15
 
16
16
  let :port do
17
- rand(2**16)
17
+ 50_000
18
18
  end
19
19
 
20
20
  let :base_uri do
21
21
  "http://#{host}:#{port}/v1"
22
22
  end
23
23
 
24
+ let :machines_uri do
25
+ "http://#{host}:#{port}/v1/machines"
26
+ end
27
+
28
+ let :leader_uri do
29
+ "http://#{host}:#{port}/v1/leader"
30
+ end
31
+
32
+ let :machines do
33
+ %W[http://#{host}:#{port} http://#{host}:#{port + 2} http://#{host}:#{port + 1}]
34
+ end
35
+
36
+ before do
37
+ stub_request(:get, leader_uri).to_return(body: "http://#{host}:#{port}")
38
+ stub_request(:get, machines_uri).to_return(body: machines.join(', '))
39
+ end
40
+
41
+ describe '#connect' do
42
+ it 'gets the list of machines' do
43
+ client
44
+ WebMock.should have_requested(:get, machines_uri)
45
+ end
46
+
47
+ it 'raises an error when the seed node cannot be contacted' do
48
+ stub_request(:get, machines_uri).to_timeout
49
+ expect { client }.to raise_error(ConnectionError)
50
+ end
51
+
52
+ it 'discards the seed node and instead talks to the leader (the first in the list of machines)' do
53
+ machines.push(machines.shift)
54
+ stub_request(:get, machines_uri).to_return(body: machines.join(','))
55
+ stub_request(:get, "#{base_uri}/keys/foo").to_return(body: MultiJson.dump({'value' => 'wrong value'}))
56
+ stub_request(:get, "#{machines[0]}/v1/keys/foo").to_return(body: MultiJson.dump({'value' => 'right value'}))
57
+ client.get('/foo').should == 'right value'
58
+ end
59
+ end
60
+
24
61
  describe '#get' do
25
62
  before do
26
63
  stub_request(:get, "#{base_uri}/keys/foo").to_return(body: MultiJson.dump({'value' => 'bar'}))
@@ -55,6 +92,66 @@ module Etcd
55
92
  client.get('/foo').should eql({'/foo/bar' => 'bar', '/foo/baz' => 'baz'})
56
93
  end
57
94
  end
95
+
96
+ context 'when not talking to the master' do
97
+ before do
98
+ stub_request(:get, "#{base_uri}/keys/foo").to_return(status: 307, headers: {'Location' => 'http://example.com:7654/v1/keys/foo'})
99
+ stub_request(:get, 'http://example.com:7654/v1/machines').to_return(body: %w[http://example.com:7654 http://example.com:7655].join(', '))
100
+ stub_request(:get, 'http://example.com:7654/v1/keys/foo').to_return(body: MultiJson.dump({'value' => 'bar'}))
101
+ end
102
+
103
+ it 'follows redirects' do
104
+ client.get('/foo').should == 'bar'
105
+ end
106
+
107
+ it 'sets the new host as the new leader when it is redirected' do
108
+ client.get('/foo')
109
+ client.get('/foo').should == 'bar'
110
+ WebMock.should have_requested(:get, "#{base_uri}/keys/foo").once
111
+ end
112
+
113
+ it 'asks for a new list of cluster machines' do
114
+ client.get('/foo')
115
+ WebMock.should have_requested(:get, 'http://example.com:7654/v1/machines').once
116
+ end
117
+ end
118
+
119
+ context 'when nodes go down' do
120
+ let :uris do
121
+ machines.map { |m| "#{m}/v1/keys/thing" }
122
+ end
123
+
124
+ it 'tries with another node when the leader appears to be down' do
125
+ stub_request(:get, uris[0]).to_timeout
126
+ stub_request(:get, uris[1]).to_return(body: MultiJson.dump({'value' => 'bar'}))
127
+ stub_request(:get, uris[2]).to_return(body: MultiJson.dump({'value' => 'baz'}))
128
+ client.get('/thing').should == 'bar'
129
+ end
130
+
131
+ it 'tries other nodes until it finds one that responds' do
132
+ stub_request(:get, uris[0]).to_timeout
133
+ stub_request(:get, uris[1]).to_timeout
134
+ stub_request(:get, uris[2]).to_return(body: MultiJson.dump({'value' => 'bar'}))
135
+ client.get('/thing').should == 'bar'
136
+ end
137
+
138
+ it 'follows redirects when trying a node after a leader failure, and saves the new leader' do
139
+ stub_request(:get, uris[0]).to_timeout
140
+ stub_request(:get, uris[1]).to_return(status: 307, headers: {'Location' => uris[2]})
141
+ stub_request(:get, uris[2]).to_return(body: MultiJson.dump({'value' => 'bar'}))
142
+ stub_request(:get, uris[2].sub('/keys/thing', '/machines')).to_return(body: [uris[1], uris[2]].join(', '))
143
+ stub_request(:get, uris[2].sub('/thing', '/bar')).to_return(body: MultiJson.dump({'value' => 'baz'}))
144
+ client.get('/thing').should == 'bar'
145
+ client.get('/bar').should == 'baz'
146
+ end
147
+
148
+ it 'raises an error when all leader candidates have been exhausted' do
149
+ stub_request(:get, uris[0]).to_timeout
150
+ stub_request(:get, uris[1]).to_timeout
151
+ stub_request(:get, uris[2]).to_timeout
152
+ expect { client.get('/thing') }.to raise_error(AllNodesDownError)
153
+ end
154
+ end
58
155
  end
59
156
 
60
157
  describe '#set' do
@@ -341,5 +438,14 @@ module Etcd
341
438
  new_keys.should == [true, nil, nil]
342
439
  end
343
440
  end
441
+
442
+ describe '#machines' do
443
+ it 'returns a list of host and ports of the machines in the etcd cluster' do
444
+ body = 'http://host01:4001, http://host02:4001, http://host03:4001'
445
+ stub_request(:get, machines_uri).to_return(body: body)
446
+ stub_request(:get, machines_uri.sub(base_uri, 'http://host01:4001/v1')).to_return(body: body)
447
+ client.machines.should == %w[http://host01:4001 http://host02:4001 http://host03:4001]
448
+ end
449
+ end
344
450
  end
345
451
  end
@@ -0,0 +1,58 @@
1
+ # encoding: utf-8
2
+
3
+ require 'spec_helper'
4
+ require 'open-uri'
5
+
6
+
7
+ describe 'A etcd client' do
8
+ let :client do
9
+ Etcd::Client.new(uri: ENV['ETCD_URI']).connect
10
+ end
11
+
12
+ let :prefix do
13
+ "/etcd-rb/#{rand(234234)}"
14
+ end
15
+
16
+ let :key do
17
+ "#{prefix}/hello"
18
+ end
19
+
20
+ before do
21
+ WebMock.disable!
22
+ end
23
+
24
+ before do
25
+ begin
26
+ open("#{ENV['ETCD_URI']}/v1/leader").read
27
+ rescue Errno::ECONNREFUSED, Errno::ENOENT
28
+ pending('etcd not running, start it with `./spec/resources/etcd-cluster start`')
29
+ end
30
+ end
31
+
32
+ before do
33
+ client.delete(key)
34
+ end
35
+
36
+ it 'sets and gets the value for a key' do
37
+ client.set(key, 'foo')
38
+ client.get(key).should == 'foo'
39
+ end
40
+
41
+ it 'sets a key with a TTL' do
42
+ client.set(key, 'foo', ttl: 5)
43
+ client.info(key)[:ttl].should be_within(1).of(5)
44
+ client.info(key)[:expiration].should be_within(1).of(Time.now + 5)
45
+ end
46
+
47
+ it 'watches for changes to a key' do
48
+ Thread.start { sleep(0.1); client.set(key, 'baz') }
49
+ new_value = client.watch(key) { |value| value }
50
+ new_value.should == 'baz'
51
+ end
52
+
53
+ it 'conditionally sets the value for a key' do
54
+ client.set(key, 'bar')
55
+ client.update(key, 'qux', 'baz').should be_false
56
+ client.update(key, 'qux', 'bar').should be_true
57
+ end
58
+ end
data/spec/spec_helper.rb CHANGED
@@ -1,5 +1,24 @@
1
1
  # encoding: utf-8
2
2
 
3
3
  require 'bundler/setup'
4
+
5
+ unless ENV['COVERAGE'] == 'no'
6
+ require 'coveralls'
7
+ require 'simplecov'
8
+
9
+ if ENV.include?('TRAVIS')
10
+ Coveralls.wear!
11
+ SimpleCov.formatter = Coveralls::SimpleCov::Formatter
12
+ end
13
+
14
+ SimpleCov.start do
15
+ add_group 'Source', 'lib'
16
+ add_group 'Unit tests', 'spec/etcd'
17
+ add_group 'Integration tests', 'spec/integration'
18
+ end
19
+ end
20
+
21
+ ENV['ETCD_URI'] ||= 'http://127.0.0.1:4001'
22
+
23
+ require 'webmock/rspec'
4
24
  require 'etcd'
5
- require 'webmock/rspec'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: etcd-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0.pre0
4
+ version: 1.0.0.pre1
5
5
  prerelease: 6
6
6
  platform: ruby
7
7
  authors:
@@ -9,8 +9,40 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2013-08-04 00:00:00.000000000 Z
13
- dependencies: []
12
+ date: 2013-08-27 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: httpclient
16
+ requirement: !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ~>
20
+ - !ruby/object:Gem::Version
21
+ version: 2.3.0
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ~>
28
+ - !ruby/object:Gem::Version
29
+ version: 2.3.0
30
+ - !ruby/object:Gem::Dependency
31
+ name: multi_json
32
+ requirement: !ruby/object:Gem::Requirement
33
+ none: false
34
+ requirements:
35
+ - - ~>
36
+ - !ruby/object:Gem::Version
37
+ version: 1.7.0
38
+ type: :runtime
39
+ prerelease: false
40
+ version_requirements: !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ~>
44
+ - !ruby/object:Gem::Version
45
+ version: 1.7.0
14
46
  description: ''
15
47
  email:
16
48
  - theo@iconara.net
@@ -23,6 +55,7 @@ files:
23
55
  - lib/etcd.rb
24
56
  - README.md
25
57
  - spec/etcd/client_spec.rb
58
+ - spec/integration/etcd_spec.rb
26
59
  - spec/spec_helper.rb
27
60
  homepage: http://github.com/iconara/etcd-rb
28
61
  licenses:
@@ -45,11 +78,12 @@ required_rubygems_version: !ruby/object:Gem::Requirement
45
78
  version: 1.3.1
46
79
  requirements: []
47
80
  rubyforge_project:
48
- rubygems_version: 1.8.23
81
+ rubygems_version: 1.8.25
49
82
  signing_key:
50
83
  specification_version: 3
51
84
  summary: ''
52
85
  test_files:
53
86
  - spec/etcd/client_spec.rb
87
+ - spec/integration/etcd_spec.rb
54
88
  - spec/spec_helper.rb
55
89
  has_rdoc: