zeevex_cluster 0.2.1
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/.gitignore +5 -0
- data/Gemfile +22 -0
- data/Rakefile +44 -0
- data/doc/BUGS-zookeeper.txt +60 -0
- data/doc/TODO.txt +85 -0
- data/lib/zeevex_cluster/base.rb +95 -0
- data/lib/zeevex_cluster/coordinator/base_key_val_store.rb +85 -0
- data/lib/zeevex_cluster/coordinator/memcached.rb +118 -0
- data/lib/zeevex_cluster/coordinator/mysql.rb +396 -0
- data/lib/zeevex_cluster/coordinator/redis.rb +101 -0
- data/lib/zeevex_cluster/coordinator.rb +29 -0
- data/lib/zeevex_cluster/election.rb +102 -0
- data/lib/zeevex_cluster/message.rb +52 -0
- data/lib/zeevex_cluster/nil_logger.rb +7 -0
- data/lib/zeevex_cluster/serializer/json_hash.rb +67 -0
- data/lib/zeevex_cluster/serializer.rb +27 -0
- data/lib/zeevex_cluster/static.rb +67 -0
- data/lib/zeevex_cluster/strategy/base.rb +92 -0
- data/lib/zeevex_cluster/strategy/cas.rb +403 -0
- data/lib/zeevex_cluster/strategy/static.rb +55 -0
- data/lib/zeevex_cluster/strategy/unclustered.rb +9 -0
- data/lib/zeevex_cluster/strategy/zookeeper.rb +163 -0
- data/lib/zeevex_cluster/strategy.rb +12 -0
- data/lib/zeevex_cluster/synchronized.rb +46 -0
- data/lib/zeevex_cluster/unclustered.rb +11 -0
- data/lib/zeevex_cluster/util/logging.rb +7 -0
- data/lib/zeevex_cluster/util.rb +15 -0
- data/lib/zeevex_cluster/version.rb +3 -0
- data/lib/zeevex_cluster.rb +29 -0
- data/script/election.rb +46 -0
- data/script/memc.rb +13 -0
- data/script/mysql.rb +25 -0
- data/script/redis.rb +14 -0
- data/script/repl +10 -0
- data/script/repl.rb +8 -0
- data/script/ser.rb +11 -0
- data/script/static.rb +34 -0
- data/script/testall +2 -0
- data/spec/cluster_static_spec.rb +49 -0
- data/spec/cluster_unclustered_spec.rb +32 -0
- data/spec/coordinator/coordinator_memcached_spec.rb +102 -0
- data/spec/message_spec.rb +38 -0
- data/spec/serializer/json_hash_spec.rb +68 -0
- data/spec/shared_master_examples.rb +20 -0
- data/spec/shared_member_examples.rb +39 -0
- data/spec/shared_non_master_examples.rb +8 -0
- data/spec/spec_helper.rb +14 -0
- data/zeevex_cluster.gemspec +43 -0
- metadata +298 -0
@@ -0,0 +1,396 @@
|
|
1
|
+
require 'zeevex_cluster/coordinator/base_key_val_store'
|
2
|
+
|
3
|
+
#
|
4
|
+
# example setup for mysql coordinator:
|
5
|
+
#
|
6
|
+
# grant all privileges on zcluster.* to 'zcluster'@localhost identified by 'zclusterp';
|
7
|
+
#
|
8
|
+
# drop table kvstore;
|
9
|
+
#
|
10
|
+
# create table kvstore (keyname varchar(255) not null,
|
11
|
+
# value mediumtext,
|
12
|
+
# namespace varchar(255) default '',
|
13
|
+
# flags integer default 0,
|
14
|
+
# created_at datetime,
|
15
|
+
# expires_at datetime,
|
16
|
+
# updated_at datetime,
|
17
|
+
# lock_version integer default 0);
|
18
|
+
#
|
19
|
+
# create unique index keyname_idx on kvstore (keyname, namespace);
|
20
|
+
#
|
21
|
+
|
22
|
+
#
|
23
|
+
# expired key handling hasn't been well-tested
|
24
|
+
#
|
25
|
+
module ZeevexCluster::Coordinator
|
26
|
+
class Mysql < BaseKeyValStore
|
27
|
+
ERR_DUPLICATE_KEY = 1062
|
28
|
+
|
29
|
+
def self.setup
|
30
|
+
unless @setup
|
31
|
+
require 'mysql2'
|
32
|
+
BaseKeyValStore.setup
|
33
|
+
|
34
|
+
@setup = true
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def initialize(options = {})
|
39
|
+
super
|
40
|
+
@table = @options[:table] || 'kvstore'
|
41
|
+
@logger = @options[:logger] || Logger.new(STDOUT)
|
42
|
+
@namespace = @options.fetch(:namespace, '')
|
43
|
+
@client ||= Mysql2::Client.new(:host => options[:server] || 'localhost',
|
44
|
+
:port => options[:port] | 3306,
|
45
|
+
:database => options[:database] || 'zcluster',
|
46
|
+
:username => options[:username],
|
47
|
+
:password => options[:password],
|
48
|
+
:reconnect => true,
|
49
|
+
:symbolize_keys => true,
|
50
|
+
:cache_rows => false,
|
51
|
+
:application_timezone => :utc,
|
52
|
+
:database_timezone => :utc)
|
53
|
+
end
|
54
|
+
|
55
|
+
#
|
56
|
+
# Add the value for a key to the DB if there is no existing entry
|
57
|
+
# Serializes unless :raw => true
|
58
|
+
#
|
59
|
+
# Returns true if key was added, false if key already had a value
|
60
|
+
#
|
61
|
+
def add(key, value, options = {})
|
62
|
+
key = to_key(key)
|
63
|
+
value = serialize_value(value, is_raw?(options))
|
64
|
+
res = do_insert_row({:keyname => key, :value => value, :namespace => @namespace},
|
65
|
+
:expiration => options.fetch(:expiration, @expiration))
|
66
|
+
res[:affected_rows] == 1
|
67
|
+
rescue Mysql2::Error => e
|
68
|
+
case e.error_number
|
69
|
+
# duplicate key
|
70
|
+
# see http://www.briandunning.com/error-codes/?source=MySQL
|
71
|
+
when ERR_DUPLICATE_KEY
|
72
|
+
false
|
73
|
+
else
|
74
|
+
raise ZeevexCluster::Coordinator::ConnectionError.new, "Unhandled mysql error: #{e.errno} #{e.message}", e
|
75
|
+
end
|
76
|
+
rescue
|
77
|
+
raise ZeevexCluster::Coordinator::ConnectionError.new 'Connection error', $!
|
78
|
+
end
|
79
|
+
|
80
|
+
#
|
81
|
+
# Set the value for a key, serializing unless :raw => true
|
82
|
+
#
|
83
|
+
def set(key, value, options = {})
|
84
|
+
key = to_key(key)
|
85
|
+
value = serialize_value(value, is_raw?(options))
|
86
|
+
row = {:keyname => key, :value => value}
|
87
|
+
|
88
|
+
res = do_upsert_row(row, :expiration => options.fetch(:expiration, @expiration), :skip_locking => true)
|
89
|
+
res[:success]
|
90
|
+
rescue ::Mysql2::Error
|
91
|
+
raise ZeevexCluster::Coordinator::ConnectionError.new 'Connection error', $!
|
92
|
+
end
|
93
|
+
|
94
|
+
#
|
95
|
+
# Delete key from database; true if key existed beforehand
|
96
|
+
#
|
97
|
+
def delete(key, options = {})
|
98
|
+
res = do_delete_row(:keyname => to_key(key))
|
99
|
+
res[:success]
|
100
|
+
end
|
101
|
+
|
102
|
+
#
|
103
|
+
# Block is passed the current value, and returns the updated value.
|
104
|
+
#
|
105
|
+
# Block can raise DontChange to simply exit the block without updating.
|
106
|
+
#
|
107
|
+
# returns nil for no value
|
108
|
+
# returns false for failure (somebody else set)
|
109
|
+
# returns true for success
|
110
|
+
#
|
111
|
+
def cas(key, options = {})
|
112
|
+
key = to_key(key)
|
113
|
+
|
114
|
+
orig_row = do_get_first(key)
|
115
|
+
return nil unless orig_row
|
116
|
+
|
117
|
+
expiration = options.fetch(:expiration, @expiration)
|
118
|
+
|
119
|
+
newval = serialize_value(yield(deserialize_value(orig_row[:value], is_raw?(options))), is_raw?(options))
|
120
|
+
updates = {:value => newval}
|
121
|
+
res = do_update_row(simple_cond(orig_row), updates, :expiration => options.fetch(:expiration, @expiration))
|
122
|
+
case res
|
123
|
+
when false then false
|
124
|
+
when true then true
|
125
|
+
else
|
126
|
+
raise ZeevexCluster::Coordinator::ConnectionError, "Unhandled return value from do_update_row - #{res.inspect}"
|
127
|
+
end
|
128
|
+
rescue ZeevexCluster::Coordinator::DontChange => e
|
129
|
+
false
|
130
|
+
rescue ::Mysql2::Error
|
131
|
+
logger.error "got error in cas: #{$!.inspect}"
|
132
|
+
raise ZeevexCluster::Coordinator::ConnectionError.new 'Connection error', $!
|
133
|
+
rescue
|
134
|
+
logger.error "got general error in cas: #{$!.inspect}"
|
135
|
+
raise
|
136
|
+
end
|
137
|
+
|
138
|
+
#
|
139
|
+
# Fetch the value for a key, deserializing unless :raw => true
|
140
|
+
#
|
141
|
+
def get(key, options = {})
|
142
|
+
key = to_key(key)
|
143
|
+
row = do_get_first key
|
144
|
+
return nil if row.nil?
|
145
|
+
|
146
|
+
if !is_raw?(options)
|
147
|
+
deserialize_value(row[:value])
|
148
|
+
else
|
149
|
+
row[:value]
|
150
|
+
end
|
151
|
+
rescue ::Mysql2::Error
|
152
|
+
raise ZeevexCluster::Coordinator::ConnectionError.new 'Connection error', $!
|
153
|
+
end
|
154
|
+
|
155
|
+
# append string vlaue to an entry
|
156
|
+
# does NOT serialize
|
157
|
+
def append(key, str, options = {})
|
158
|
+
newval = Literal.new %{CONCAT(value, #{qval str})}
|
159
|
+
do_update_row({:keyname => to_key(key)}, {:value => newval})
|
160
|
+
rescue ::Mysql2::Error
|
161
|
+
raise ZeevexCluster::Coordinator::ConnectionError.new 'Connection error', $!
|
162
|
+
end
|
163
|
+
|
164
|
+
# prepend string value to an entry
|
165
|
+
# does NOT serialize
|
166
|
+
def prepend(key, str, options = {})
|
167
|
+
newval = Literal.new %{CONCAT(#{qval str}, value)}
|
168
|
+
do_update_row({:keyname => to_key(key)}, {:value => newval})
|
169
|
+
rescue ::Mysql2::Error
|
170
|
+
raise ZeevexCluster::Coordinator::ConnectionError.new 'Connection error', $!
|
171
|
+
end
|
172
|
+
|
173
|
+
protected
|
174
|
+
|
175
|
+
# mysql get wrapper. returns just the resultset as a list of hashes
|
176
|
+
# which may be nil or empty list if none matched.
|
177
|
+
def do_get(key, options = {})
|
178
|
+
conditions = []
|
179
|
+
conditions << %{#{qcol 'keyname'} = #{qval key}}
|
180
|
+
conditions << %{#{qcol 'namespace'} = #{qval @namespace}}
|
181
|
+
query(%{SELECT * from #@table where #{conditions.join(' AND ')};})[:resultset]
|
182
|
+
end
|
183
|
+
|
184
|
+
def do_get_first(*args)
|
185
|
+
res = do_get(*args)
|
186
|
+
res && res.first
|
187
|
+
end
|
188
|
+
|
189
|
+
def simple_cond(row)
|
190
|
+
slice_hash row, :namespace, :keyname, :lock_version
|
191
|
+
end
|
192
|
+
|
193
|
+
def make_comparison(trip)
|
194
|
+
trip = case trip.count
|
195
|
+
when 1 then [trip[0], 'IS NOT', nil]
|
196
|
+
when 2 then [trip[0], '=', trip[1]]
|
197
|
+
when 3 then trip
|
198
|
+
else raise 'Must have 1-3 arguments'
|
199
|
+
end
|
200
|
+
%{#{qcol trip[0]} #{trip[1]} #{qval trip[2]}}
|
201
|
+
end
|
202
|
+
|
203
|
+
def make_row_conditions(cond)
|
204
|
+
cond = {:namespace => @namespace}.merge(cond)
|
205
|
+
make_conditions(cond)
|
206
|
+
end
|
207
|
+
|
208
|
+
def make_conditions(cond)
|
209
|
+
case cond
|
210
|
+
when String then cond
|
211
|
+
when Array then cond.map {|trip| make_comparison(trip) }.join(' AND ')
|
212
|
+
when Hash then cond.map {|(k,v)| make_comparison([k, v].flatten) }.join(' AND ')
|
213
|
+
else raise "Unknown condition format: #{cond.inspect}"
|
214
|
+
end
|
215
|
+
end
|
216
|
+
|
217
|
+
def do_insert_row(row, options = {})
|
218
|
+
(row[:keyname] && row[:value]) or raise ArgumentError, 'Must specify at least key and value'
|
219
|
+
now = self.now
|
220
|
+
row = row.merge(:namespace => @namespace)
|
221
|
+
row = {:created_at => now, :updated_at => now}.merge(row) unless options[:skip_timestamps]
|
222
|
+
row = {:expires_at => now + options[:expiration]}.merge(row) if options[:expiration]
|
223
|
+
res = query %{INSERT INTO #@table (#{row.keys.map {|k| qcol(k)}.join(', ')})
|
224
|
+
values (#{row.values.map {|k| qval(k)}.join(', ')});}
|
225
|
+
res[:success] = (res[:affected_rows] == 1)
|
226
|
+
res
|
227
|
+
end
|
228
|
+
|
229
|
+
def do_upsert_row(row, options = {})
|
230
|
+
(row[:keyname] && row[:value]) or raise ArgumentError, 'Must specify at least key and value'
|
231
|
+
now = self.now
|
232
|
+
|
233
|
+
## what values are set if the row is inserted
|
234
|
+
row = row.merge(:namespace => @namespace)
|
235
|
+
row = {:created_at => now, :updated_at => now}.merge(row) unless options[:skip_timestamps]
|
236
|
+
row = {:expires_at => now + options[:expiration]}.merge(row) if options[:expiration]
|
237
|
+
|
238
|
+
## values updated if row already exists
|
239
|
+
# these columns shouldn't be set on update
|
240
|
+
updatable_row = trim_hash(row, [:created_at, :keyname, :namespace, :lock_version])
|
241
|
+
# update of a row should increment the lock version, rather than setting it
|
242
|
+
updatable_row.merge!(:lock_version => Literal.new('lock_version + 1')) unless options[:skip_locking]
|
243
|
+
|
244
|
+
res = query %{INSERT INTO #@table (#{row.keys.map {|k| qcol(k)}.join(', ')})
|
245
|
+
values (#{row.values.map {|k| qval(k)}.join(', ')})
|
246
|
+
ON DUPLICATE KEY UPDATE
|
247
|
+
#{updatable_row.map {|(k,v)| "#{qcol k} = #{qval v}"}.join(', ')};}
|
248
|
+
|
249
|
+
# see http://dev.mysql.com/doc/refman/5.0/en/insert-on-duplicate.html for WTF affected_rows
|
250
|
+
# overloading
|
251
|
+
res[:success] = [1,2].include?(res[:affected_rows])
|
252
|
+
res[:upsert_type] = case res[:affected_rows]
|
253
|
+
when 1 then :insert
|
254
|
+
when 2 then :update
|
255
|
+
else :none
|
256
|
+
end
|
257
|
+
res
|
258
|
+
end
|
259
|
+
|
260
|
+
#
|
261
|
+
# note, unlike some of the do_* functions, returns a simple boolean for success
|
262
|
+
#
|
263
|
+
def do_update_row(quals, newattrvals, options = {})
|
264
|
+
quals[:keyname] or raise 'Must specify at least the full key in an update'
|
265
|
+
conditions = 'WHERE ' + make_row_conditions(quals)
|
266
|
+
newattrvals = {:updated_at => now}.merge(newattrvals) unless options[:skip_timestamps]
|
267
|
+
newattrvals = {:expires_at => now + options[:expiration]}.merge(newattrvals) if options[:expiration]
|
268
|
+
newattrvals.merge!({:lock_version => Literal.new('lock_version + 1')}) unless options[:skip_locking]
|
269
|
+
updates = newattrvals.map do |(key, val)|
|
270
|
+
"#{qcol key} = #{qval val}"
|
271
|
+
end
|
272
|
+
statement = %{UPDATE #@table SET #{updates.join(', ')} #{conditions};}
|
273
|
+
res = query statement
|
274
|
+
res[:affected_rows] == 0 ? false : true
|
275
|
+
end
|
276
|
+
|
277
|
+
def do_delete_row(quals, options = {})
|
278
|
+
quals[:keyname] or raise 'Must specify at least the key in a delete'
|
279
|
+
conditions = 'WHERE ' + make_row_conditions(quals)
|
280
|
+
statement = %{DELETE from #@table #{conditions};}
|
281
|
+
res = query statement
|
282
|
+
res[:success] = res[:affected_rows] > 0
|
283
|
+
res
|
284
|
+
end
|
285
|
+
|
286
|
+
def clear_expired_rows
|
287
|
+
statement = %{DELETE FROM #@table WHERE #{qcol 'expires_at'} < #{qnow} and #{qcol 'namespace'} = #{qval @namespace};}
|
288
|
+
@client.query statement
|
289
|
+
true
|
290
|
+
rescue ::Mysql2::Error
|
291
|
+
log_exception($!, statement)
|
292
|
+
false
|
293
|
+
rescue
|
294
|
+
logger.error %{Unhandled error in query: #{$!.inspect}\nstatement=[#{statement}]\n#{$!.backtrace.join("\n")}}
|
295
|
+
end
|
296
|
+
|
297
|
+
#
|
298
|
+
# chokepoint for *most* queries issued to MySQL, except the one from `clear_expired_rows` as we call it.
|
299
|
+
#
|
300
|
+
# returns a hash containing values returned from mysql2 API
|
301
|
+
#
|
302
|
+
def query(statement, options = {})
|
303
|
+
unless options[:ignore_expiration]
|
304
|
+
clear_expired_rows
|
305
|
+
end
|
306
|
+
logger.debug "[#{statement}]"
|
307
|
+
res = @client.query statement, options
|
308
|
+
{:resultset => res, :affected_rows => @client.affected_rows, :last_id => @client.last_id}
|
309
|
+
rescue ::Mysql2::Error
|
310
|
+
log_exception($!, statement)
|
311
|
+
raise
|
312
|
+
rescue
|
313
|
+
logger.error %{Unhandled error in query: #{$!.inspect}\nstatement=[#{statement}]\n#{$!.backtrace.join("\n")}}
|
314
|
+
raise
|
315
|
+
end
|
316
|
+
|
317
|
+
#
|
318
|
+
# extract a hash with a subset of keys
|
319
|
+
#
|
320
|
+
def slice_hash(src, *keys)
|
321
|
+
hash = src.class.new
|
322
|
+
Array(keys).flatten.each { |k| hash[k] = src[k] if src.has_key?(k) }
|
323
|
+
hash
|
324
|
+
end
|
325
|
+
|
326
|
+
#
|
327
|
+
# return a new hash with a set of keys removed
|
328
|
+
#
|
329
|
+
def trim_hash(src, *keys)
|
330
|
+
hash = src.class.new
|
331
|
+
keys = Array(keys).flatten
|
332
|
+
src.keys.each { |k| hash[k] = src[k] unless keys.include?(k) }
|
333
|
+
hash
|
334
|
+
end
|
335
|
+
|
336
|
+
def log_exception(e, statement=nil)
|
337
|
+
logger.error %{Mysql exception errno=#{e.errno}, sql_state=#{e.sql_state}, message=#{e.message}, statement=[#{statement || 'UNKNOWN'}]\n#{e.backtrace.join("\n")}}
|
338
|
+
end
|
339
|
+
|
340
|
+
# quote quotes in a quotable string.
|
341
|
+
def quote_string(s)
|
342
|
+
s.gsub(/\\/, '\&\&').gsub(/'/, "''") # ' (for ruby-mode)
|
343
|
+
end
|
344
|
+
|
345
|
+
# quote a column name
|
346
|
+
def qcol(colname)
|
347
|
+
%{`#{colname}`}
|
348
|
+
end
|
349
|
+
|
350
|
+
# quote a value - takes the quoting/translation style from the Ruby type of the value itself
|
351
|
+
# rather than the column definition as e.g. ActiveRecord might.
|
352
|
+
def qval(val)
|
353
|
+
case val
|
354
|
+
when Literal then val
|
355
|
+
when String then %{'#{quote_string val}'}
|
356
|
+
when true then '1'
|
357
|
+
when false then '0'
|
358
|
+
when nil then 'NULL'
|
359
|
+
when Bignum then val.to_s('F')
|
360
|
+
when Numeric then val.to_s
|
361
|
+
when Time then qval(val.utc.strftime('%Y-%m-%d-%H:%M:%S'))
|
362
|
+
else val
|
363
|
+
end
|
364
|
+
end
|
365
|
+
|
366
|
+
# quoted time value for now
|
367
|
+
def qnow
|
368
|
+
qval now
|
369
|
+
end
|
370
|
+
|
371
|
+
#
|
372
|
+
# now, as a Time, in UTC
|
373
|
+
#
|
374
|
+
def now
|
375
|
+
Time.now.utc
|
376
|
+
end
|
377
|
+
|
378
|
+
#
|
379
|
+
# unlike the base implementation, we don't fold the namespace into the key
|
380
|
+
# we leave that in a separate column
|
381
|
+
#
|
382
|
+
def to_key(key)
|
383
|
+
if @options[:to_key_proc]
|
384
|
+
@options[:to_key_proc].call(key)
|
385
|
+
else
|
386
|
+
key.to_s
|
387
|
+
end
|
388
|
+
end
|
389
|
+
|
390
|
+
#
|
391
|
+
# class used to indicate a value to be passed to MySQL unquoted; useful for
|
392
|
+
# e.g. arithmetic expressions
|
393
|
+
#
|
394
|
+
class Literal < String; end
|
395
|
+
end
|
396
|
+
end
|
@@ -0,0 +1,101 @@
|
|
1
|
+
require 'zeevex_cluster/coordinator/base_key_val_store'
|
2
|
+
|
3
|
+
module ZeevexCluster::Coordinator
|
4
|
+
class Redis < BaseKeyValStore
|
5
|
+
def self.setup
|
6
|
+
unless @setup
|
7
|
+
require 'redis'
|
8
|
+
BaseKeyValStore.setup
|
9
|
+
@setup = true
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
def initialize(options = {})
|
14
|
+
super
|
15
|
+
@client ||= ::Redis.new :host => @server, :port => @port
|
16
|
+
end
|
17
|
+
|
18
|
+
def add(key, value, options = {})
|
19
|
+
if @client.setnx(to_key(key), serialize_value(value, is_raw?(options)))
|
20
|
+
@client.expire to_key(key), options.fetch(:expiration, @expiration)
|
21
|
+
true
|
22
|
+
else
|
23
|
+
false
|
24
|
+
end
|
25
|
+
rescue ::Redis::CannotConnectError
|
26
|
+
raise ZeevexCluster::Coordinator::ConnectionError.new 'Connection error', $!
|
27
|
+
end
|
28
|
+
|
29
|
+
def set(key, value, options = {})
|
30
|
+
status( @client.setex(to_key(key),
|
31
|
+
options.fetch(:expiration, @expiration),
|
32
|
+
serialize_value(value, is_raw?(options))) ) == STATUS_OK
|
33
|
+
rescue ::Redis::CannotConnectError
|
34
|
+
raise ZeevexCluster::Coordinator::ConnectionError.new 'Connection error', $!
|
35
|
+
end
|
36
|
+
|
37
|
+
def delete(key, options = {})
|
38
|
+
@client.del(to_key(key)) == 1
|
39
|
+
rescue ::Redis::CannotConnectError
|
40
|
+
raise ZeevexCluster::Coordinator::ConnectionError.new 'Connection error', $!
|
41
|
+
end
|
42
|
+
|
43
|
+
#
|
44
|
+
# Block is passed the current value, and returns the updated value.
|
45
|
+
#
|
46
|
+
# Block can raise DontChange to simply exit the block without updating.
|
47
|
+
#
|
48
|
+
# returns nil for no value
|
49
|
+
# returns false for failure (somebody else set)
|
50
|
+
# returns true for success
|
51
|
+
#
|
52
|
+
def cas(key, options = {})
|
53
|
+
key = to_key(key)
|
54
|
+
@client.unwatch
|
55
|
+
@client.watch key
|
56
|
+
orig_val = @client.get key
|
57
|
+
return nil if orig_val.nil?
|
58
|
+
|
59
|
+
expiration = options.fetch(:expiration, @expiration)
|
60
|
+
|
61
|
+
newval = serialize_value(yield(deserialize_value(orig_val, is_raw?(options))), is_raw?(options))
|
62
|
+
res = @client.multi do
|
63
|
+
if expiration
|
64
|
+
@client.setex key, expiration, newval
|
65
|
+
else
|
66
|
+
@client.set key, newval
|
67
|
+
end
|
68
|
+
end
|
69
|
+
@client.unwatch
|
70
|
+
case res
|
71
|
+
when nil then false
|
72
|
+
when Array then true
|
73
|
+
else raise "Unhandled return value from multi - #{res.inspect}"
|
74
|
+
end
|
75
|
+
rescue ZeevexCluster::Coordinator::DontChange => e
|
76
|
+
false
|
77
|
+
rescue ::Redis::CannotConnectError
|
78
|
+
raise ZeevexCluster::Coordinator::ConnectionError.new 'Connection error', $!
|
79
|
+
end
|
80
|
+
|
81
|
+
def get(key, options = {})
|
82
|
+
deserialize_value(@client.get(to_key(key)), is_raw?(options))
|
83
|
+
rescue ::Redis::CannotConnectError
|
84
|
+
raise ZeevexCluster::Coordinator::ConnectionError.new 'Connection error', $!
|
85
|
+
end
|
86
|
+
|
87
|
+
protected
|
88
|
+
|
89
|
+
STATUS_OK = 'OK'
|
90
|
+
|
91
|
+
def status(response)
|
92
|
+
case response
|
93
|
+
when nil then nil
|
94
|
+
when String then response.chomp
|
95
|
+
else
|
96
|
+
raise ArgumentError, 'This should only be called on results from set / setex, etc.'
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
end
|
101
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module ZeevexCluster
|
2
|
+
module Coordinator
|
3
|
+
# flow control exceptions used in these classes
|
4
|
+
class DontChange < StandardError; end
|
5
|
+
|
6
|
+
# errors throw by these classes
|
7
|
+
class CoordinatorError < StandardError
|
8
|
+
attr_accessor :chained
|
9
|
+
def initialize(message, original = nil)
|
10
|
+
@chained = original
|
11
|
+
super(message)
|
12
|
+
end
|
13
|
+
def to_s
|
14
|
+
@chained ? super + "; chained = #{@chained.inspect}" : super
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
class ConnectionError < CoordinatorError; end
|
19
|
+
class ConsistencyError < CoordinatorError; end
|
20
|
+
|
21
|
+
def self.create(coordinator_type, options)
|
22
|
+
require 'zeevex_cluster/coordinator/' + coordinator_type
|
23
|
+
clazz = self.const_get(coordinator_type.capitalize)
|
24
|
+
raise ArgumentError, "Unknown coordinator type: #{coordinator_type}" unless clazz
|
25
|
+
ZeevexCluster.Synchronized(clazz.new(options))
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
@@ -0,0 +1,102 @@
|
|
1
|
+
require 'zeevex_cluster/strategy'
|
2
|
+
require 'zeevex_cluster/strategy/cas'
|
3
|
+
|
4
|
+
module ZeevexCluster
|
5
|
+
class Election < Base
|
6
|
+
|
7
|
+
def initialize(options = {})
|
8
|
+
super
|
9
|
+
raise ArgumentError, 'Must specify :cluster_name' unless options[:cluster_name]
|
10
|
+
|
11
|
+
if options[:hooks]
|
12
|
+
add_hooks(options[:hooks])
|
13
|
+
end
|
14
|
+
|
15
|
+
unless (@strategy = options[:strategy])
|
16
|
+
stype = options[:strategy_type] || 'cas'
|
17
|
+
@strategy = ZeevexCluster::Strategy.create(stype,
|
18
|
+
{:nodename => options.fetch(:nodename, Socket.gethostname),
|
19
|
+
:cluster_name => options[:cluster_name],
|
20
|
+
:logger => options[:logger]}.
|
21
|
+
merge(options[:backend_options]))
|
22
|
+
end
|
23
|
+
@strategy.add_hook_observer Proc.new { |*args| hook_observer(*args) }
|
24
|
+
|
25
|
+
after_initialize
|
26
|
+
end
|
27
|
+
|
28
|
+
def master?
|
29
|
+
member? && @strategy.am_i_master?
|
30
|
+
end
|
31
|
+
|
32
|
+
##
|
33
|
+
## Make this node the master, returning true if successful. No-op for now.
|
34
|
+
##
|
35
|
+
def make_master!
|
36
|
+
return unless member?
|
37
|
+
if @strategy.steal_election!
|
38
|
+
true
|
39
|
+
else
|
40
|
+
false
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
##
|
45
|
+
## Resign from mastership; returns false if this is the only node.
|
46
|
+
##
|
47
|
+
## No-op for now.
|
48
|
+
##
|
49
|
+
def resign!(delay = nil)
|
50
|
+
@strategy.resign delay
|
51
|
+
end
|
52
|
+
|
53
|
+
def campaign!
|
54
|
+
@strategy.start unless @strategy.started?
|
55
|
+
# stop sitting out the election
|
56
|
+
@strategy.resign 0
|
57
|
+
end
|
58
|
+
|
59
|
+
##
|
60
|
+
## Return name of master node
|
61
|
+
##
|
62
|
+
def master
|
63
|
+
member? && @strategy.master_node && @strategy.master_node[:nodename]
|
64
|
+
end
|
65
|
+
|
66
|
+
def join
|
67
|
+
return if member?
|
68
|
+
@member = true
|
69
|
+
@strategy.start unless @strategy.started?
|
70
|
+
end
|
71
|
+
|
72
|
+
def leave
|
73
|
+
return unless member?
|
74
|
+
resign! if master?
|
75
|
+
@strategy.stop if @strategy.started?
|
76
|
+
ensure
|
77
|
+
@member = false
|
78
|
+
end
|
79
|
+
|
80
|
+
def member?
|
81
|
+
@strategy.member?
|
82
|
+
end
|
83
|
+
|
84
|
+
def members
|
85
|
+
member? && (@strategy.respond_to?(:members) ? @strategy.members : [@nodename])
|
86
|
+
end
|
87
|
+
|
88
|
+
protected
|
89
|
+
|
90
|
+
def hook_observer(hook_name, source, *args)
|
91
|
+
logger.debug "#{self.class} observed hook: #{hook_name} #{args.inspect}"
|
92
|
+
case hook_name
|
93
|
+
when :status_change
|
94
|
+
run_hook :status_change, args[0], args[1]
|
95
|
+
when :cluster_status_change
|
96
|
+
run_hook :cluster_status_change, args[0], args[1]
|
97
|
+
else
|
98
|
+
run_hook "strategy_#{hook_name}".to_sym, *args
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
require 'zeevex_cluster/serializer'
|
2
|
+
|
3
|
+
module ZeevexCluster
|
4
|
+
class Message < Hash
|
5
|
+
|
6
|
+
include ZeevexCluster::Serializer
|
7
|
+
|
8
|
+
REQUIRED_KEYS = %w{source sequence sent_at expires_at contents content_type}.
|
9
|
+
map {|x| x.to_sym}
|
10
|
+
ALLOWED_KEYS = %w{vclock options flags encoding}.
|
11
|
+
map {|x| x.to_sym}
|
12
|
+
FORBIDDEN_KEYS = ['$primitive', '$type', '$encoding']
|
13
|
+
|
14
|
+
ALL_KEYS = REQUIRED_KEYS + ALLOWED_KEYS
|
15
|
+
|
16
|
+
def initialize(hash)
|
17
|
+
super()
|
18
|
+
hash.keys.each do |x|
|
19
|
+
raise ArgumentError, 'Only symbol keys are allowed in Messages' unless x.is_a?(Symbol)
|
20
|
+
end
|
21
|
+
self.merge! :content_type => 'application/json'
|
22
|
+
self.merge! hash
|
23
|
+
end
|
24
|
+
|
25
|
+
def valid?
|
26
|
+
vkeys = self.keys
|
27
|
+
(REQUIRED_KEYS - vkeys).empty? &&
|
28
|
+
(FORBIDDEN_KEYS & vkeys).empty?
|
29
|
+
end
|
30
|
+
|
31
|
+
def respond_to?(meth)
|
32
|
+
if ALL_KEYS.include?(meth.to_s.chomp('=').to_sym)
|
33
|
+
true
|
34
|
+
else
|
35
|
+
super
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
protected
|
40
|
+
|
41
|
+
def method_missing(meth, *args, &block)
|
42
|
+
if ALL_KEYS.include?(meth.to_sym)
|
43
|
+
self[meth]
|
44
|
+
elsif ALL_KEYS.include?(key = meth.to_s.chomp('=').to_sym)
|
45
|
+
self[key] = args[0]
|
46
|
+
else
|
47
|
+
super
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
end
|
52
|
+
end
|