etcd-rb 1.0.0.pre1 → 1.0.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.
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