etcd-rb 1.0.0.pre1 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
data/lib/etcd/client.rb CHANGED
@@ -1,415 +1,25 @@
1
1
  # encoding: utf-8
2
2
 
3
- require 'time'
4
- require 'thread'
5
- require 'httpclient'
6
- require 'multi_json'
7
-
8
-
9
3
  module Etcd
10
- # A client for `etcd`. Implements all core operations (`get`, `set`, `delete`
11
- # and `watch`) and features (TTL, atomic test-and-set, listing directories,
12
- # etc).
13
- #
14
- # In addition to the core operations there are a few convenience methods for
15
- # doing test-and-set (a.k.a. compare-and-swap, atomic update), and continuous
16
- # watching.
17
- #
18
- # @note All methods that take a key or prefix as argument will prepend a slash
19
- # to the key if it does not start with slash.
20
- #
21
- # @example Basic usage
22
- # client = Etcd::Client.new
23
- # client.set('/foo/bar', 'baz')
24
- # client.get('/foo/bar') # => 'baz'
25
- # client.delete('/foo/bar') # => 'baz'
26
- #
27
- # @example Make a key expire automatically after 5s
28
- # client.set('/foo', 'bar', ttl: 5)
29
- #
30
- # @example Atomic updates
31
- # client.set('/foo/bar', 'baz')
32
- # # ...
33
- # if client.update('/foo/bar', 'qux', 'baz')
34
- # puts 'Nobody changed our data'
35
- # end
36
- #
37
- # @example Listing a directory
38
- # client.set('/foo/bar', 'baz')
39
- # client.set('/foo/qux', 'fizz')
40
- # client.get('/foo') # => {'/foo/bar' => 'baz', '/foo/qux' => 'fizz'}
41
- #
42
- # @example Getting info for a key
43
- # client.set('/foo', 'bar', ttl: 5)
44
- # client.info('/foo') # => {:key => '/foo',
45
- # # :value => '/bar',
46
- # # :expires => Time.utc(...),
47
- # # :ttl => 4}
48
- #
49
- # @example Observing changes to a key
50
- # observer = client.observe('/foo') do |value, key|
51
- # # This will be run asynchronously
52
- # puts "The key #{key}" changed to #{value}"
53
- # end
54
- # client.set('/foo/bar', 'baz') # "The key /foo/bar changed to baz" is printed
55
- # client.set('/foo/qux', 'fizz') # "The key /foo/qux changed to fizz" is printed
56
- # # stop receiving change notifications
57
- # observer.cancel
58
- #
59
4
  class Client
60
- # Creates a new `etcd` client.
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
- #
74
- # @param [Hash] options
75
- # @option options [String] :uri ('http://127.0.0.1:4001') The etcd host and
76
- # port to connect to
77
- def initialize(options={})
78
- @seed_uri = options[:uri] || 'http://127.0.0.1:4001'
79
- @protocol_version = 'v1'
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
91
- end
92
-
93
- # Sets the value of a key.
94
- #
95
- # Accepts an optional `:ttl` which is the number of seconds that the key
96
- # should live before being automatically deleted.
97
- #
98
- # @param key [String] the key to set
99
- # @param value [String] the value to set
100
- # @param options [Hash]
101
- # @option options [Fixnum] :ttl (nil) an optional time to live (in seconds)
102
- # for the key
103
- # @return [String] The previous value (if any)
104
- def set(key, value, options={})
105
- body = {:value => value}
106
- if ttl = options[:ttl]
107
- body[:ttl] = ttl
108
- end
109
- response = request(:post, uri(key), body: body)
110
- data = MultiJson.load(response.body)
111
- data[S_PREV_VALUE]
112
- end
113
-
114
- # Atomically sets the value for a key if the current value for the key
115
- # matches the specified expected value.
116
- #
117
- # Returns `true` when the operation succeeds, i.e. when the specified
118
- # expected value matches the current value. Returns `false` otherwise.
119
- #
120
- # Accepts an optional `:ttl` which is the number of seconds that the key
121
- # should live before being automatically deleted.
122
- #
123
- # @param key [String] the key to set
124
- # @param value [String] the value to set
125
- # @param expected_value [String] the value to compare to the current value
126
- # @param options [Hash]
127
- # @option options [Fixnum] :ttl (nil) an optional time to live (in seconds)
128
- # for the key
129
- # @return [true, false] whether or not the operation succeeded
130
- def update(key, value, expected_value, options={})
131
- body = {:value => value, :prevValue => expected_value}
132
- if ttl = options[:ttl]
133
- body[:ttl] = ttl
134
- end
135
- response = request(:post, uri(key), body: body)
136
- response.status == 200
137
- end
138
-
139
- # Gets the value or values for a key.
140
- #
141
- # If the key represents a directory with direct decendants (e.g. "/foo" for
142
- # "/foo/bar") a hash of keys and values will be returned.
143
- #
144
- # @param key [String] the key or prefix to retrieve
145
- # @return [String, Hash] the value for the key, or a hash of keys and values
146
- # when the key is a prefix.
147
- def get(key)
148
- response = request(:get, uri(key))
149
- if response.status == 200
150
- data = MultiJson.load(response.body)
151
- if data.is_a?(Array)
152
- data.each_with_object({}) do |e, acc|
153
- acc[e[S_KEY]] = e[S_VALUE]
154
- end
155
- else
156
- data[S_VALUE]
157
- end
158
- else
159
- nil
160
- end
161
- end
162
-
163
- # Returns info about a key, such as TTL, expiration and index.
164
- #
165
- # For keys with values the returned hash will include `:key`, `:value` and
166
- # `:index`. Additionally for keys with a TTL set there will be a `:ttl` and
167
- # `:expiration` (as a UTC `Time`).
168
- #
169
- # For keys that represent directories with no direct decendants (e.g. "/foo"
170
- # for "/foo/bar/baz") the `:dir` key will have the value `true`.
171
- #
172
- # For keys that represent directories with direct decendants (e.g. "/foo"
173
- # for "/foo/bar") a hash of keys and info will be returned.
174
- #
175
- # @param key [String] the key or prefix to retrieve
176
- # @return [Hash] a with info about the key, the exact contents depend on
177
- # what kind of key it is.
178
- def info(key)
179
- response = request(:get, uri(key))
180
- if response.status == 200
181
- data = MultiJson.load(response.body)
182
- if data.is_a?(Array)
183
- data.each_with_object({}) do |d, acc|
184
- info = extract_info(d)
185
- info.delete(:action)
186
- acc[info[:key]] = info
187
- end
188
- else
189
- info = extract_info(data)
190
- info.delete(:action)
191
- info
192
- end
193
- else
194
- nil
195
- end
196
- end
197
-
198
- # Remove a key and its value.
199
- #
200
- # The previous value is returned, or `nil` if the key did not exist.
201
- #
202
- # @param key [String] the key to remove
203
- # @return [String] the previous value, if any
204
- def delete(key)
205
- response = request(:delete, uri(key))
206
- if response.status == 200
207
- data = MultiJson.load(response.body)
208
- data[S_PREV_VALUE]
209
- else
210
- nil
211
- end
212
- end
213
-
214
- # Returns true if the specified key exists.
215
- #
216
- # This is a convenience method and equivalent to calling {#get} and checking
217
- # if the value is `nil`.
218
- #
219
- # @return [true, false] whether or not the specified key exists
220
- def exists?(key)
221
- !!get(key)
222
- end
223
-
224
- # Watches a key or prefix and calls the given block when with any changes.
225
- #
226
- # This method will block until the server replies. There is no way to cancel
227
- # the call.
228
- #
229
- # The parameters to the block are the value, the key and a hash of
230
- # additional info. The info will contain the `:action` that caused the
231
- # change (`:set`, `:delete` etc.), the `:key`, the `:value`, the `:index`,
232
- # `:new_key` with the value `true` when a new key was created below the
233
- # watched prefix, `:previous_value`, if any, `:ttl` and `:expiration` if
234
- # applicable.
235
- #
236
- # The reason why the block parameters are in the order`value`, `key` instead
237
- # of `key`, `value` is because you almost always want to get the new value
238
- # when you watch, but not always the key, and most often not the info. With
239
- # this order you can leave out the parameters you don't need.
240
- #
241
- # @param prefix [String] the key or prefix to watch
242
- # @param options [Hash]
243
- # @option options [Fixnum] :index (nil) the index to start watching from
244
- # @yieldparam [String] value the value of the key that changed
245
- # @yieldparam [String] key the key that changed
246
- # @yieldparam [Hash] info the info for the key that changed
247
- # @return [Object] the result of the given block
248
- def watch(prefix, options={})
249
- parameters = {}
250
- if index = options[:index]
251
- parameters[:index] = index
252
- end
253
- response = request(:get, uri(prefix, S_WATCH), query: parameters)
254
- data = MultiJson.load(response.body)
255
- info = extract_info(data)
256
- yield info[:value], info[:key], info
257
- end
258
-
259
- # Sets up a continuous watch of a key or prefix.
260
- #
261
- # This method works like {#watch} (which is used behind the scenes), but
262
- # will re-watch the key or prefix after receiving a change notificiation.
263
- #
264
- # When re-watching the index of the previous change notification is used,
265
- # so no subsequent changes will be lost while a change is being processed.
266
- #
267
- # Unlike {#watch} this method as asynchronous. The watch handler runs in a
268
- # separate thread (currently a new thread is created for each invocation,
269
- # keep this in mind if you need to watch many different keys), and can be
270
- # cancelled by calling `#cancel` on the returned object.
271
- #
272
- # Because of implementation details the watch handler thread will not be
273
- # stopped directly when you call `#cancel`. The thread will be blocked until
274
- # the next change notification (which will be ignored). This will have very
275
- # little effect on performance since the thread will not be runnable. Unless
276
- # you're creating lots of observers it should not matter. If you want to
277
- # make sure you wait for the thread to stop you can call `#join` on the
278
- # returned object.
279
- #
280
- # @example Creating and cancelling an observer
281
- # observer = client.observe('/foo') do |value|
282
- # # do something on changes
283
- # end
284
- # # ...
285
- # observer.cancel
286
- #
287
- # @return [#cancel, #join] an observer object which you can call cancel and
288
- # join on
289
- def observe(prefix, &handler)
290
- Observer.new(self, prefix, handler).tap(&:run)
291
- end
5
+ include Etcd::Constants
6
+ include Etcd::Requestable
7
+ include Etcd::Loggable
292
8
 
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
-
303
- private
304
-
305
- S_KEY = 'key'.freeze
306
- S_KEYS = 'keys'.freeze
307
- S_VALUE = 'value'.freeze
308
- S_INDEX = 'index'.freeze
309
- S_EXPIRATION = 'expiration'.freeze
310
- S_TTL = 'ttl'.freeze
311
- S_NEW_KEY = 'newKey'.freeze
312
- S_DIR = 'dir'.freeze
313
- S_PREV_VALUE = 'prevValue'.freeze
314
- S_ACTION = 'action'.freeze
315
- S_WATCH = 'watch'.freeze
316
- S_LOCATION = 'location'.freeze
317
-
318
- S_SLASH = '/'.freeze
319
- MACHINES_SEPARATOR_RE = /,\s*/
320
-
321
- def uri(key, action=S_KEYS)
322
- key = "/#{key}" unless key.start_with?(S_SLASH)
323
- "#{@base_uri}/#{action}#{key}"
324
- end
9
+ attr_accessor :cluster
10
+ attr_accessor :leader
11
+ attr_accessor :seed_uris
12
+ attr_accessor :heartbeat_freq
13
+ attr_accessor :observers
14
+ attr_accessor :status # :up/:down
325
15
 
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
16
+ def inspect
17
+ %Q(<Etcd::Client #{seed_uris}>)
333
18
  end
334
19
 
335
- def extract_info(data)
336
- info = {
337
- :key => data[S_KEY],
338
- :value => data[S_VALUE],
339
- :index => data[S_INDEX],
340
- }
341
- expiration_s = data[S_EXPIRATION]
342
- ttl = data[S_TTL]
343
- previous_value = data[S_PREV_VALUE]
344
- action_s = data[S_ACTION]
345
- info[:expiration] = Time.iso8601(expiration_s) if expiration_s
346
- info[:ttl] = ttl if ttl
347
- info[:new_key] = data[S_NEW_KEY] if data.include?(S_NEW_KEY)
348
- info[:dir] = data[S_DIR] if data.include?(S_DIR)
349
- info[:previous_value] = previous_value if previous_value
350
- info[:action] = action_s.downcase.to_sym if action_s
351
- info
352
- end
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
-
380
- # @private
381
- class Observer
382
- def initialize(client, prefix, handler)
383
- @client = client
384
- @prefix = prefix
385
- @handler = handler
386
- end
387
-
388
- def run
389
- @running = true
390
- index = nil
391
- @thread = Thread.start do
392
- while @running
393
- @client.watch(@prefix, index: index) do |value, key, info|
394
- if @running
395
- index = info[:index]
396
- @handler.call(value, key, info)
397
- end
398
- end
399
- end
400
- end
401
- self
402
- end
403
-
404
- def cancel
405
- @running = false
406
- self
407
- end
408
-
409
- def join
410
- @thread.join
411
- self
412
- end
413
- end
414
20
  end
415
21
  end
22
+
23
+ require 'etcd/client/protocol'
24
+ require 'etcd/client/observing'
25
+ require 'etcd/client/failover'
@@ -0,0 +1,98 @@
1
+ module Etcd
2
+ class Cluster
3
+ attr_accessor :nodes
4
+ attr_accessor :seed_uri
5
+
6
+ class << self
7
+ include Etcd::Requestable
8
+ include Etcd::Constants
9
+ include Etcd::Loggable
10
+
11
+ # @example
12
+ # Etcd::Cluster.cluster_status("http://127.0.0.1:4001")
13
+ #
14
+ # @return [Array] of node attributes as [Hash]
15
+ def cluster_status(uri)
16
+ begin
17
+ logger.debug("cluster_status - from #{uri}")
18
+ data = request_data(:get, status_uri(uri))
19
+ parse_cluster_status(data)
20
+ rescue Errno::ECONNREFUSED => e
21
+ logger.debug("cluster_status - error!")
22
+ nil
23
+ end
24
+ end
25
+
26
+ def status_uri(uri)
27
+ "#{uri}/v1/keys/_etcd/machines/"
28
+ end
29
+
30
+ def parse_cluster_status(cluster_status_response)
31
+ cluster_status_response.map do |attrs|
32
+ node_name = attrs[S_KEY].split(S_SLASH).last
33
+ urls = attrs[S_VALUE].split(S_AND)
34
+ etcd = urls.grep(/etcd/).first.split("=").last
35
+ raft = urls.grep(/raft/).first.split("=").last
36
+ {:name => node_name, :raft => raft, :etcd => etcd}
37
+ end
38
+ end
39
+
40
+
41
+
42
+ # @example
43
+ # Etcd::Cluster.nodes_from_uri("http://127.0.0.1:4001")
44
+ #
45
+ # @return [Array] of [Node] instances, representing cluster status
46
+ # @see #nodes_from_attributes
47
+ def nodes_from_uri(uri)
48
+ node_attributes = cluster_status(uri)
49
+ nodes_from_attributes(node_attributes)
50
+ end
51
+
52
+ def nodes_from_attributes(node_attributes)
53
+ res = node_attributes.map do |attr|
54
+ Etcd::Node.new(attr)
55
+ end
56
+ end
57
+
58
+ # creates new cluster with updated status
59
+ # is preferred way to create a cluster instance
60
+ # @example
61
+ # Etcd::Cluster.init_from_uris("http://127.0.0.1:4001", "http://127.0.0.1:4002", "http://127.0.0.1:4003")
62
+ #
63
+ # @return [Cluster] instance with live data in nodes
64
+ def init_from_uris(*uris)
65
+ Array(uris).each do |uri|
66
+ if Etcd::Cluster.cluster_status(uri)
67
+ instance = Etcd::Cluster.new(uri)
68
+ instance.update_status
69
+ return instance
70
+ end
71
+ end
72
+ raise AllNodesDownError, "could not initialize a cluster from #{uris.join(", ")}"
73
+ end
74
+ end
75
+
76
+ def initialize(uri)
77
+ @seed_uri = uri
78
+ end
79
+
80
+ def nodes
81
+ @nodes ||= update_status
82
+ end
83
+
84
+ def update_status
85
+ @nodes = begin
86
+ nodes = Etcd::Cluster.nodes_from_uri(seed_uri)
87
+ nodes.map{|x| x.update_status}
88
+ nodes
89
+ end
90
+ end
91
+
92
+ # leader instance in cluster
93
+ # @return [Node]
94
+ def leader
95
+ nodes.select{|x| x.is_leader}.first
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,18 @@
1
+ module Etcd
2
+ module Constants
3
+ S_KEY = 'key'.freeze
4
+ S_KEYS = 'keys'.freeze
5
+ S_VALUE = 'value'.freeze
6
+ S_INDEX = 'index'.freeze
7
+ S_EXPIRATION = 'expiration'.freeze
8
+ S_TTL = 'ttl'.freeze
9
+ S_NEW_KEY = 'newKey'.freeze
10
+ S_DIR = 'dir'.freeze
11
+ S_PREV_VALUE = 'prevValue'.freeze
12
+ S_ACTION = 'action'.freeze
13
+ S_WATCH = 'watch'.freeze
14
+ S_LOCATION = 'location'.freeze
15
+ S_SLASH = '/'.freeze
16
+ S_AND = '&'.freeze
17
+ end
18
+ end
@@ -0,0 +1,44 @@
1
+ module Etcd
2
+ class Heartbeat
3
+ include Etcd::Loggable
4
+
5
+ attr_accessor :client
6
+ attr_accessor :freq
7
+ def initialize(client, freq)
8
+ @client = client
9
+ @freq = freq
10
+ end
11
+
12
+
13
+ # Initiates heartbeating the leader node in a background thread
14
+ # ensures, that observers are refreshed after leader re-election
15
+ def start_heartbeat_if_needed
16
+ logger.debug ("start_heartbeat_if_needed - enter")
17
+ return if freq == 0
18
+ return if @heartbeat_thread
19
+ @heartbeat_thread = Thread.new do
20
+ while true do
21
+ heartbeat_command
22
+ end
23
+ end
24
+ end
25
+
26
+
27
+ # The command to check leader online status,
28
+ # runs in background and is resilient to failures
29
+ def heartbeat_command
30
+ logger.debug("heartbeat_command: enter ")
31
+ logger.debug(client.observers_overview.join(", "))
32
+ begin
33
+ client.refresh_observers_if_needed
34
+ client.update_cluster if client.status == :down
35
+ client.get("foo")
36
+ rescue Exception => e
37
+ client.status = :down
38
+ logger.debug "heartbeat - #{e.message} #{e.backtrace}"
39
+ end
40
+ sleep freq
41
+ end
42
+
43
+ end
44
+ end
@@ -0,0 +1,13 @@
1
+ module Etcd::Loggable
2
+ def logger(level=Logger::WARN)
3
+ @logger ||= reset_logger!(level)
4
+ end
5
+
6
+ def reset_logger!(level=Logger::WARN)
7
+ @logger = begin
8
+ log = Logger.new(STDOUT)
9
+ log.level = level
10
+ log
11
+ end
12
+ end
13
+ end
data/lib/etcd/node.rb ADDED
@@ -0,0 +1,45 @@
1
+ module Etcd
2
+ class Node
3
+ include Etcd::Constants
4
+ include Etcd::Requestable
5
+ attr_accessor :name, :etcd, :raft
6
+ # possible values: :unknown, :running, :down
7
+ attr_accessor :status
8
+ attr_accessor :is_leader
9
+
10
+ def initialize(opts={})
11
+ check_required(opts)
12
+ @name = opts[:name]
13
+ @etcd = URI.decode(opts[:etcd])
14
+ @raft = URI.decode(opts[:raft])
15
+ @status = :unknown
16
+ end
17
+
18
+ def check_required(opts)
19
+ raise ArgumentError, "etcd URL is required!" unless opts[:etcd]
20
+ end
21
+
22
+ def update_status
23
+ begin
24
+ response = request(:get, leader_uri)
25
+ @status = :running
26
+ @is_leader = (response.body == @raft)
27
+ rescue HTTPClient::TimeoutError, Errno::ECONNREFUSED => e
28
+ @status = :down
29
+ end
30
+ end
31
+
32
+ def leader_uri
33
+ "#{@etcd}/v1/leader"
34
+ end
35
+
36
+ def inspect
37
+ %Q(<#{self.class} - #{name_with_status} - #{etcd}>)
38
+ end
39
+
40
+ def name_with_status
41
+ print_status = @is_leader ? "leader" : status
42
+ "#{name} (#{print_status})"
43
+ end
44
+ end
45
+ end