-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathprimitive_consul_lock.rb
More file actions
213 lines (179 loc) · 6.57 KB
/
Copy pathprimitive_consul_lock.rb
File metadata and controls
213 lines (179 loc) · 6.57 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
require_relative 'consul'
require_relative 'primitive'
require 'chef/mash'
require 'json'
# This primitive is based on optimistic concurrency (using compare-and-swap) rather than consul sessions.
# It allows to support the unavailability of the local consul agent (for reboot, reinstall, ...)
module Choregraphie
class ConsulError < StandardError
def initialize(msg)
super
end
end
class ConsulLock < Primitive
def initialize(options = {}, &block)
@options = Mash.new(options)
# path in consul (name_of_the_lock)
validate!(:path, String)
# id of this node
validate!(:id, String)
if @options[:concurrency] && @options[:service]
raise ArgumentError, "You can't set both concurrency and service"
end
@options[:backoff] ||= 5 # seconds
@options[:max_wait] ||= false
ConsulCommon.setup_consul(@options)
# this block could be used to configure diplomat if needed
yield if block_given?
end
def concurrency
@concurrency ||= case
when @options[:service]
require 'diplomat'
opts = @options[:service][:options] || {}
opts[:dc] = @options[:datacenter] if @options[:datacenter]
opts[:token] = @options[:token] if @options[:token]
total = Diplomat::Service.get(
@options[:service][:name],
:all,
opts
).count
# TODO: check only passing instances
[1,(@options[:service][:concurrency_ratio] * total).to_i].max
when @options[:concurrency]
@options[:concurrency]
else
1
end
end
def semaphore_class
Semaphore
end
def semaphore
# this object cannot be reused after enter/exit
semaphore_class.get_or_create(path, concurrency, dc: @options[:datacenter], token: @options[:token])
end
def backoff
Chef::Log.warn "Will sleep #{@options[:backoff]}"
sleep @options[:backoff]
false # indicates failure
end
def wait_until(action, opts = {})
dc = "(in #{@options[:datacenter]})" if @options[:datacenter]
Chef::Log.info "Will #{action} the lock #{path} #{dc}"
start_time = Time.now
success = 0.upto(opts[:max_failures] || Float::INFINITY).any? do |tries|
begin
yield
rescue => e
Chef::Log.warn "Error while #{action}-ing lock"
Chef::Log.warn e
elapsed = Time.now - start_time
if @options[:max_wait] && elapsed > @options[:max_wait]
raise "Max time of #{@options[:max_wait]} reached while #{action}-ing lock, failing"
end
backoff
end
end
if success
Chef::Log.info "#{action.to_s.capitalize}ed the lock #{path}"
else
Chef::Log.warn "Will ignore errors and since we've reached #{opts[:max_failures]} errors"
end
end
def register(choregraphie)
choregraphie.before do
wait_until(:enter) { semaphore.enter(name: @options[:id]) }
end
choregraphie.finish do
# hack: We can ignore failure there since it is only to release
# the lock. If there is a temporary failure, we can wait for the
# next run to release the lock without compromising safety.
# The reason we have to be a bit more relaxed here, is that all
# chef run including a choregraphie with this primitive try to
# release the lock at the end of a successful run
wait_until(:exit, max_failures: 5) { semaphore.exit(name: @options[:id]) }
end
end
private
def path
@options[:path].sub(/^\//,'')
end
end
class Semaphore
def self.get_or_create(path, concurrency, dc: nil, token: nil)
require 'diplomat'
retry_left = 5
value = Mash.new({ version: 1, concurrency: concurrency, holders: {} })
current_lock = begin
Chef::Log.info "Fetch lock state for #{path}"
Diplomat::Kv.get(path, decode_values: true, dc: dc, token: token)
rescue Diplomat::KeyNotFound
Chef::Log.info "Lock for #{path} did not exist, creating with value #{value}"
Diplomat::Kv.put(path, value.to_json, cas: 0, dc: dc, token: token) # we ignore success/failure of CaS
(retry_left -= 1) > 0 ? retry : raise
end.first
desired_lock = bootstrap_lock(value, current_lock)
new(path, desired_lock, dc, token: token)
end
def self.bootstrap_lock(desired_value, current_lock)
desired_lock = current_lock.dup
desired_value = desired_value.dup
current_holders = Mash.new(JSON.parse(current_lock['Value'])['holders'])
desired_value['holders'] = current_holders
desired_lock['Value'] = desired_value.to_json
desired_lock
end
def initialize(path, new_lock, dc = nil, token: nil)
@path = path
@h = JSON.parse(new_lock['Value'])
@cas = new_lock['ModifyIndex']
@dc = dc
@token = token
end
def already_entered?(opts)
name = opts[:name] # this is the server id
holders.has_key?(name.to_s) # lock is re-entrant
end
def can_enter_lock?(opts)
holders.size < concurrency
end
def enter_lock(opts)
name = opts[:name] # this is the server id
holders[name.to_s] ||= Time.now
end
def enter(opts)
return if already_entered?(opts)
if can_enter_lock?(opts)
enter_lock(opts)
require 'diplomat'
result = Diplomat::Kv.put(@path, to_json, cas: @cas, dc: @dc, token: @token)
raise ConsulError.new("Someone updated the lock at the same time, will retry") unless result
else
raise ConsulError.new("Too many lock holders (concurrency:#{concurrency})")
end
end
def exit_lock(opts)
name = opts[:name] # this is the server id
holders.delete(name.to_s)
end
def exit(opts)
if already_entered?(opts)
exit_lock(opts)
require 'diplomat'
result = Diplomat::Kv.put(@path, to_json, cas: @cas, dc: @dc, token: @token)
raise ConsulError.new("Someone updated the lock at the same time, will retry") unless result
end
end
private
def to_json
@h.to_json
end
def concurrency
@h['concurrency']
end
def holders
@h['holders']
end
end
end