promiscuous 0.100.5 → 1.0.0.beta1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/lib/promiscuous.rb +5 -1
- data/lib/promiscuous/config.rb +6 -5
- data/lib/promiscuous/dsl.rb +0 -4
- data/lib/promiscuous/loader.rb +0 -5
- data/lib/promiscuous/mongoid.rb +15 -5
- data/lib/promiscuous/publisher.rb +1 -1
- data/lib/promiscuous/publisher/model/active_record.rb +6 -1
- data/lib/promiscuous/publisher/model/base.rb +8 -11
- data/lib/promiscuous/publisher/model/mock.rb +2 -2
- data/lib/promiscuous/publisher/model/mongoid.rb +3 -4
- data/lib/promiscuous/publisher/operation/active_record.rb +13 -69
- data/lib/promiscuous/publisher/operation/atomic.rb +15 -158
- data/lib/promiscuous/publisher/operation/base.rb +13 -381
- data/lib/promiscuous/publisher/operation/ephemeral.rb +12 -8
- data/lib/promiscuous/publisher/operation/mongoid.rb +22 -92
- data/lib/promiscuous/publisher/operation/non_persistent.rb +0 -9
- data/lib/promiscuous/publisher/operation/proxy_for_query.rb +8 -6
- data/lib/promiscuous/publisher/operation/transaction.rb +4 -56
- data/lib/promiscuous/publisher/transport.rb +14 -0
- data/lib/promiscuous/publisher/transport/batch.rb +138 -0
- data/lib/promiscuous/publisher/transport/persistence.rb +14 -0
- data/lib/promiscuous/publisher/transport/persistence/active_record.rb +33 -0
- data/lib/promiscuous/publisher/transport/persistence/mongoid.rb +22 -0
- data/lib/promiscuous/publisher/transport/worker.rb +36 -0
- data/lib/promiscuous/publisher/worker.rb +3 -12
- data/lib/promiscuous/redis.rb +5 -0
- data/lib/promiscuous/subscriber/message.rb +1 -29
- data/lib/promiscuous/subscriber/model/base.rb +3 -2
- data/lib/promiscuous/subscriber/model/mongoid.rb +16 -1
- data/lib/promiscuous/subscriber/model/observer.rb +0 -1
- data/lib/promiscuous/subscriber/operation.rb +9 -3
- data/lib/promiscuous/subscriber/unit_of_work.rb +7 -7
- data/lib/promiscuous/subscriber/worker/eventual_destroyer.rb +1 -1
- data/lib/promiscuous/version.rb +1 -1
- metadata +39 -35
- data/lib/promiscuous/dependency.rb +0 -78
- data/lib/promiscuous/error/dependency.rb +0 -116
@@ -1,25 +1,10 @@
|
|
1
1
|
class Promiscuous::Publisher::Operation::Base
|
2
|
-
|
3
|
-
self.recovery_mechanisms = []
|
4
|
-
|
5
|
-
def self.register_recovery_mechanism(method_name=nil, &block)
|
6
|
-
self.recovery_mechanisms << (block || method(method_name))
|
7
|
-
end
|
8
|
-
|
9
|
-
def self.run_recovery_mechanisms
|
10
|
-
self.recovery_mechanisms.each(&:call)
|
11
|
-
end
|
12
|
-
|
13
|
-
attr_accessor :operation
|
2
|
+
attr_accessor :operation, :recovering
|
14
3
|
|
15
4
|
def initialize(options={})
|
16
5
|
@operation = options[:operation]
|
17
6
|
end
|
18
7
|
|
19
|
-
def recovering?
|
20
|
-
!!@recovery_data
|
21
|
-
end
|
22
|
-
|
23
8
|
def record_timestamp
|
24
9
|
# Records the number of milliseconds since epoch, which we use send sending
|
25
10
|
# the payload over. It's good for latency measurements.
|
@@ -27,345 +12,6 @@ class Promiscuous::Publisher::Operation::Base
|
|
27
12
|
@timestamp = time.to_i * 1000 + time.usec / 1000
|
28
13
|
end
|
29
14
|
|
30
|
-
def self.rabbitmq_staging_set_key
|
31
|
-
Promiscuous::Key.new(:pub).join('rabbitmq_staging').to_s
|
32
|
-
end
|
33
|
-
|
34
|
-
delegate :rabbitmq_staging_set_key, :to => self
|
35
|
-
|
36
|
-
def on_rabbitmq_confirm
|
37
|
-
# These requests could be parallelized, rabbitmq persisted the operation.
|
38
|
-
# XXX TODO
|
39
|
-
# Promiscuous::Redis.slave.del(@payload_recovery_key) if Promiscuous::Redis.slave
|
40
|
-
|
41
|
-
@payload_recovery_node.multi do
|
42
|
-
@payload_recovery_node.del(@payload_recovery_key)
|
43
|
-
@payload_recovery_node.zrem(rabbitmq_staging_set_key, @payload_recovery_key)
|
44
|
-
end
|
45
|
-
end
|
46
|
-
|
47
|
-
def publish_payload_in_rabbitmq_async
|
48
|
-
Promiscuous::AMQP.publish(:key => Promiscuous::Config.app, :payload => @payload,
|
49
|
-
:on_confirm => method(:on_rabbitmq_confirm))
|
50
|
-
end
|
51
|
-
|
52
|
-
def self.recover_payloads_for_rabbitmq
|
53
|
-
return unless Promiscuous::Redis.master
|
54
|
-
# This method is regularly called from a worker to resend payloads that
|
55
|
-
# never got their confirm. We get the oldest queued message, and test if
|
56
|
-
# it's old enough to for a republish (default 10 seconds).
|
57
|
-
# Any sort of race is okay since we would just republish, and that's okay.
|
58
|
-
|
59
|
-
Promiscuous::Redis.master.nodes.each do |node|
|
60
|
-
loop do
|
61
|
-
key, time = node.zrange(rabbitmq_staging_set_key, 0, 1, :with_scores => true).flatten
|
62
|
-
break unless key && Time.now.to_i >= time.to_i + Promiscuous::Config.recovery_timeout
|
63
|
-
|
64
|
-
# Refresh the score so we skip it next time we look for something to recover.
|
65
|
-
node.zadd(rabbitmq_staging_set_key, Time.now.to_i, key)
|
66
|
-
payload = node.get(key)
|
67
|
-
|
68
|
-
# It's possible that the payload is nil as the message could be
|
69
|
-
# recovered by another worker
|
70
|
-
if payload
|
71
|
-
Promiscuous.info "[payload recovery] #{payload}"
|
72
|
-
new.instance_eval do
|
73
|
-
@payload_recovery_node = node
|
74
|
-
@payload_recovery_key = key
|
75
|
-
@payload = payload
|
76
|
-
@recovery = true
|
77
|
-
publish_payload_in_rabbitmq_async
|
78
|
-
end
|
79
|
-
end
|
80
|
-
end
|
81
|
-
end
|
82
|
-
end
|
83
|
-
register_recovery_mechanism :recover_payloads_for_rabbitmq
|
84
|
-
|
85
|
-
def publish_payload_in_redis
|
86
|
-
# TODO Optimize and DRY this up
|
87
|
-
w = @committed_write_deps
|
88
|
-
|
89
|
-
# We identify a payload with a unique key (id:id_value:current_version:payload_recovery)
|
90
|
-
# to avoid collisions with other updates on the same document.
|
91
|
-
master_node = @op_lock.node
|
92
|
-
@payload_recovery_node = master_node
|
93
|
-
@payload_recovery_key = Promiscuous::Key.new(:pub).join('payload_recovery', @op_lock.token).to_s
|
94
|
-
|
95
|
-
# We need to be able to recover from a redis failure. By sending the
|
96
|
-
# payload to the slave first, we ensure that we can replay the lost
|
97
|
-
# payloads if the master came to fail.
|
98
|
-
# We still need to recover the lost operations. This can be done by doing a
|
99
|
-
# version diff from what is stored in the database and the recovered redis slave.
|
100
|
-
# XXX TODO
|
101
|
-
# Promiscuous::Redis.slave.set(@payload_recovery_key, @payload) if Promiscuous::Redis.slave
|
102
|
-
|
103
|
-
# We don't care if we get raced by someone recovering our operation. It can
|
104
|
-
# happen if we lost the lock without knowing about it.
|
105
|
-
# The payload can be sent twice, which is okay since the subscribers
|
106
|
-
# tolerate it.
|
107
|
-
operation_recovery_key = "#{@op_lock.key}:operation_recovery"
|
108
|
-
versions_recovery_key = "#{operation_recovery_key}:versions"
|
109
|
-
|
110
|
-
master_node.multi do
|
111
|
-
master_node.set(@payload_recovery_key, @payload)
|
112
|
-
master_node.zadd(rabbitmq_staging_set_key, Time.now.to_i, @payload_recovery_key)
|
113
|
-
master_node.del(operation_recovery_key)
|
114
|
-
master_node.del(versions_recovery_key)
|
115
|
-
end
|
116
|
-
|
117
|
-
# The payload is safe now. We can cleanup all the versions on the
|
118
|
-
# secondary. There are no harmful races that can happen since the
|
119
|
-
# secondary_operation_recovery_key is unique to the operation.
|
120
|
-
# XXX The caveat is that if we die here, the
|
121
|
-
# secondary_operation_recovery_key will never be cleaned up.
|
122
|
-
w.map(&:redis_node).uniq
|
123
|
-
.reject { |node| node == master_node }
|
124
|
-
.each { |node| node.del(versions_recovery_key) }
|
125
|
-
end
|
126
|
-
|
127
|
-
def payload_for(instance)
|
128
|
-
options = { :with_attributes => self.operation.in?([:create, :update]) }
|
129
|
-
instance.promiscuous.payload(options).tap do |payload|
|
130
|
-
payload[:operation] = self.operation
|
131
|
-
end
|
132
|
-
end
|
133
|
-
|
134
|
-
def generate_payload
|
135
|
-
payload = {}
|
136
|
-
payload[:operations] = operation_payloads
|
137
|
-
payload[:app] = Promiscuous::Config.app
|
138
|
-
payload[:current_user_id] = Promiscuous.context.current_user.id if Promiscuous.context.current_user
|
139
|
-
payload[:timestamp] = @timestamp
|
140
|
-
payload[:generation] = Promiscuous::Config.generation
|
141
|
-
payload[:host] = Socket.gethostname
|
142
|
-
payload[:recovered_operation] = true if recovering?
|
143
|
-
payload[:dependencies] = {}
|
144
|
-
payload[:dependencies][:write] = @committed_write_deps
|
145
|
-
|
146
|
-
@payload = MultiJson.dump(payload)
|
147
|
-
end
|
148
|
-
|
149
|
-
def self.recover_operation_from_lock(lock)
|
150
|
-
# We happen to have acquired a never released lock.
|
151
|
-
# The database instance is thus still pristine.
|
152
|
-
|
153
|
-
master_node = lock.node
|
154
|
-
recovery_data = master_node.get("#{lock.key}:operation_recovery")
|
155
|
-
|
156
|
-
unless recovery_data.present?
|
157
|
-
lock.unlock
|
158
|
-
return
|
159
|
-
end
|
160
|
-
|
161
|
-
Promiscuous.info "[operation recovery] #{lock.key} -> #{recovery_data}"
|
162
|
-
|
163
|
-
op_klass, operation, write_dependencies, recovery_arguments = *MultiJson.load(recovery_data)
|
164
|
-
|
165
|
-
operation = operation.to_sym
|
166
|
-
write_dependencies.map! { |k| Promiscuous::Dependency.parse(k.to_s, :type => :write) }
|
167
|
-
|
168
|
-
begin
|
169
|
-
op = op_klass.constantize.recover_operation(*recovery_arguments)
|
170
|
-
rescue NameError
|
171
|
-
raise "invalid recover operation class: #{op_klass}"
|
172
|
-
end
|
173
|
-
|
174
|
-
Thread.new do
|
175
|
-
# We run the recovery in another thread to ensure that we get a new
|
176
|
-
# database connection to avoid tampering with the current state of the
|
177
|
-
# connection, which can be in an open transaction.
|
178
|
-
# Thankfully, we are not in a fast path.
|
179
|
-
# Note that any exceptions will be passed through the thread join() method.
|
180
|
-
op.instance_eval do
|
181
|
-
@operation = operation
|
182
|
-
@write_dependencies = write_dependencies
|
183
|
-
@op_lock = lock
|
184
|
-
@recovery_data = recovery_data
|
185
|
-
|
186
|
-
query = Promiscuous::Publisher::Operation::ProxyForQuery.new(self) { recover_db_operation }
|
187
|
-
self.execute_instrumented(query)
|
188
|
-
query.result
|
189
|
-
end
|
190
|
-
end.join
|
191
|
-
|
192
|
-
rescue Exception => e
|
193
|
-
message = "cannot recover #{lock.key}, failed to fetch recovery data"
|
194
|
-
message = "cannot recover #{lock.key}, recovery data: #{recovery_data}" if recovery_data
|
195
|
-
raise Promiscuous::Error::Recovery.new(message, e)
|
196
|
-
end
|
197
|
-
|
198
|
-
def increment_dependencies
|
199
|
-
# We collapse all operations, ignoring the read/write interleaving.
|
200
|
-
# It doesn't matter since all write operations are serialized, so the first
|
201
|
-
# write in the transaction can have all the read dependencies.
|
202
|
-
w = write_dependencies
|
203
|
-
|
204
|
-
master_node = @op_lock.node
|
205
|
-
operation_recovery_key = "#{@op_lock.key}:operation_recovery"
|
206
|
-
|
207
|
-
# We group all the dependencies by their respective shards
|
208
|
-
# The master node will have the responsibility to hold the recovery data.
|
209
|
-
# We do the master node first. The secondaries can be done in parallel.
|
210
|
-
@committed_write_deps = []
|
211
|
-
|
212
|
-
# We need to do the increments always in the same node order, otherwise.
|
213
|
-
# the subscriber can deadlock. But we must always put the recovery payload
|
214
|
-
# on the master before touching anything.
|
215
|
-
nodes_deps = w.group_by(&:redis_node)
|
216
|
-
.sort_by { |node, deps| -Promiscuous::Redis.master.nodes.index(node) }
|
217
|
-
if nodes_deps.first[0] != master_node
|
218
|
-
nodes_deps = [[master_node, []]] + nodes_deps
|
219
|
-
end
|
220
|
-
|
221
|
-
nodes_deps.each do |node, deps|
|
222
|
-
argv = []
|
223
|
-
argv << Promiscuous::Key.new(:pub) # key prefixes
|
224
|
-
argv << operation_recovery_key
|
225
|
-
|
226
|
-
# Each shard have their own recovery payload. The master recovery node
|
227
|
-
# has the full operation recovery, and the others just have their versions.
|
228
|
-
# Note that the operation_recovery_key on the secondaries have the current
|
229
|
-
# version of the instance appended to them. It's easier to cleanup when
|
230
|
-
# locks get lost.
|
231
|
-
if node == master_node && !self.recovering?
|
232
|
-
# We are on the master node, which holds the recovery payload
|
233
|
-
argv << MultiJson.dump([self.class.name, operation, w, self.recovery_payload])
|
234
|
-
end
|
235
|
-
|
236
|
-
# FIXME If the lock is lost, we need to backoff
|
237
|
-
|
238
|
-
# We are going to store all the versions in redis, to be able to recover.
|
239
|
-
# We store all our increments in a transaction_id key in JSON format.
|
240
|
-
# Note that the transaction_id is the id of the current instance.
|
241
|
-
@@increment_script ||= Promiscuous::Redis::Script.new <<-SCRIPT
|
242
|
-
local prefix = ARGV[1] .. ':'
|
243
|
-
local operation_recovery_key = ARGV[2]
|
244
|
-
local versions_recovery_key = operation_recovery_key .. ':versions'
|
245
|
-
local operation_recovery_payload = ARGV[3]
|
246
|
-
local deps = KEYS
|
247
|
-
|
248
|
-
local versions = {}
|
249
|
-
|
250
|
-
if redis.call('exists', versions_recovery_key) == 1 then
|
251
|
-
for i, dep in ipairs(deps) do
|
252
|
-
versions[i] = tonumber(redis.call('hget', versions_recovery_key, dep))
|
253
|
-
if not versions[i] then
|
254
|
-
return redis.error_reply('Failed to read dependency ' .. dep .. ' during recovery')
|
255
|
-
end
|
256
|
-
end
|
257
|
-
|
258
|
-
return { versions }
|
259
|
-
end
|
260
|
-
|
261
|
-
for i, dep in ipairs(deps) do
|
262
|
-
local key = prefix .. dep
|
263
|
-
versions[i] = redis.call('incr', key .. ':w')
|
264
|
-
redis.call('hset', versions_recovery_key, dep, versions[i])
|
265
|
-
end
|
266
|
-
|
267
|
-
if operation_recovery_payload then
|
268
|
-
redis.call('set', operation_recovery_key, operation_recovery_payload)
|
269
|
-
end
|
270
|
-
|
271
|
-
return { versions }
|
272
|
-
SCRIPT
|
273
|
-
|
274
|
-
versions = @@increment_script.eval(node, :argv => argv, :keys => deps)
|
275
|
-
|
276
|
-
deps.zip(versions).each { |dep, version| dep.version = version }
|
277
|
-
|
278
|
-
@committed_write_deps += deps
|
279
|
-
end
|
280
|
-
|
281
|
-
# The instance version must to be the first in the list to allow atomic
|
282
|
-
# subscribers to do their magic.
|
283
|
-
# TODO What happens with transactions with multiple operations?
|
284
|
-
instance_dep_index = @committed_write_deps.index(write_dependencies.first)
|
285
|
-
@committed_write_deps[0], @committed_write_deps[instance_dep_index] =
|
286
|
-
@committed_write_deps[instance_dep_index], @committed_write_deps[0]
|
287
|
-
end
|
288
|
-
|
289
|
-
def self.lock_options
|
290
|
-
{
|
291
|
-
:timeout => 10.seconds, # after 10 seconds, we give up so we don't queue requests
|
292
|
-
:sleep => 0.01.seconds, # polling every 10ms.
|
293
|
-
:expire => 1.minute, # after one minute, we are considered dead
|
294
|
-
:lock_set => Promiscuous::Key.new(:pub).join('lock_set').to_s
|
295
|
-
}
|
296
|
-
end
|
297
|
-
delegate :lock_options, :to => self
|
298
|
-
|
299
|
-
def dependency_for_op_lock
|
300
|
-
query_dependencies.first
|
301
|
-
end
|
302
|
-
|
303
|
-
def get_new_op_lock
|
304
|
-
dep = dependency_for_op_lock
|
305
|
-
Promiscuous::Redis::Mutex.new(dep.key(:pub).to_s, lock_options.merge(:node => dep.redis_node))
|
306
|
-
end
|
307
|
-
|
308
|
-
def self._acquire_lock(mutex)
|
309
|
-
loop do
|
310
|
-
case mutex.lock
|
311
|
-
# recover_operation_from_lock implicitely unlocks the lock.
|
312
|
-
when :recovered then recover_operation_from_lock(mutex)
|
313
|
-
when true then return true
|
314
|
-
when false then return false
|
315
|
-
end
|
316
|
-
end
|
317
|
-
end
|
318
|
-
|
319
|
-
def acquire_op_lock
|
320
|
-
@op_lock = get_new_op_lock
|
321
|
-
|
322
|
-
unless self.class._acquire_lock(@op_lock)
|
323
|
-
raise Promiscuous::Error::LockUnavailable.new(@op_lock.key)
|
324
|
-
end
|
325
|
-
end
|
326
|
-
|
327
|
-
def release_op_lock
|
328
|
-
@op_lock.unlock
|
329
|
-
@op_lock = nil
|
330
|
-
end
|
331
|
-
|
332
|
-
def ensure_op_still_locked
|
333
|
-
unless @op_lock.still_locked?
|
334
|
-
# We lost the lock, let the recovery mechanism do its thing.
|
335
|
-
raise Promiscuous::Error::LostLock.new(@op_lock.key)
|
336
|
-
end
|
337
|
-
end
|
338
|
-
|
339
|
-
def self.recover_locks
|
340
|
-
return unless Promiscuous::Redis.master
|
341
|
-
# This method is regularly called from a worker to recover locks by doing a
|
342
|
-
# locking/unlocking cycle.
|
343
|
-
|
344
|
-
Promiscuous::Redis.master.nodes.each do |node|
|
345
|
-
loop do
|
346
|
-
key, time = node.zrange(lock_options[:lock_set], 0, 1, :with_scores => true).flatten
|
347
|
-
break unless key && Time.now.to_i >= time.to_i + lock_options[:expire]
|
348
|
-
|
349
|
-
mutex = Promiscuous::Redis::Mutex.new(key, lock_options.merge(:node => node))
|
350
|
-
mutex.unlock if _acquire_lock(mutex)
|
351
|
-
end
|
352
|
-
end
|
353
|
-
end
|
354
|
-
register_recovery_mechanism :recover_locks
|
355
|
-
|
356
|
-
def dependencies_for(instance, options={})
|
357
|
-
return [] if instance.nil?
|
358
|
-
|
359
|
-
# Note that tracked_dependencies will not return the id dependency if it
|
360
|
-
# doesn't exist which can only happen for create operations and auto
|
361
|
-
# generated ids.
|
362
|
-
[instance.promiscuous.get_dependency]
|
363
|
-
end
|
364
|
-
|
365
|
-
def write_dependencies
|
366
|
-
@write_dependencies ||= self.query_dependencies.uniq.each { |d| d.type = :write }
|
367
|
-
end
|
368
|
-
|
369
15
|
def should_instrument_query?
|
370
16
|
!Promiscuous.disabled?
|
371
17
|
end
|
@@ -382,37 +28,11 @@ class Promiscuous::Publisher::Operation::Base
|
|
382
28
|
query.result
|
383
29
|
end
|
384
30
|
|
385
|
-
def query_dependencies
|
386
|
-
# Returns the list of dependencies that are involved in the database query.
|
387
|
-
# For an atomic write operation, the first one returned must be the one
|
388
|
-
# corresponding to the primary key.
|
389
|
-
raise
|
390
|
-
end
|
391
|
-
|
392
31
|
def execute_instrumented(db_operation)
|
393
32
|
# Implemented by subclasses
|
394
33
|
raise
|
395
34
|
end
|
396
35
|
|
397
|
-
def operation_payloads
|
398
|
-
# subclass can use payloads_for to generate the payload
|
399
|
-
raise
|
400
|
-
end
|
401
|
-
|
402
|
-
def recovery_payload
|
403
|
-
# Overridden to be able to recover the operation
|
404
|
-
[]
|
405
|
-
end
|
406
|
-
|
407
|
-
def self.recover_operation(*recovery_payload)
|
408
|
-
# Overridden to reconstruct the operation.
|
409
|
-
end
|
410
|
-
|
411
|
-
def recover_db_operation
|
412
|
-
# Overridden to reexecute the db operation during recovery (or make sure that
|
413
|
-
# it will never succeed).
|
414
|
-
end
|
415
|
-
|
416
36
|
def trace_operation
|
417
37
|
if ENV['TRACE']
|
418
38
|
msg = self.explain_operation(70)
|
@@ -423,4 +43,16 @@ class Promiscuous::Publisher::Operation::Base
|
|
423
43
|
def explain_operation(max_width)
|
424
44
|
"Unknown database operation"
|
425
45
|
end
|
46
|
+
|
47
|
+
def create_transport_batch(operations)
|
48
|
+
Promiscuous::Publisher::Transport::Batch.new.tap do |batch|
|
49
|
+
operations.map do |operation|
|
50
|
+
batch.add operation.operation, operation.instances
|
51
|
+
end
|
52
|
+
|
53
|
+
if current_user = Promiscuous.context.current_user
|
54
|
+
batch.payload_attributes = { :current_user_id => current_user.id }
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
426
58
|
end
|
@@ -1,14 +1,18 @@
|
|
1
|
-
class Promiscuous::Publisher::Operation::Ephemeral < Promiscuous::Publisher::Operation::
|
2
|
-
def
|
3
|
-
super
|
1
|
+
class Promiscuous::Publisher::Operation::Ephemeral < Promiscuous::Publisher::Operation::Base
|
2
|
+
def initialize(options={})
|
3
|
+
super
|
4
|
+
@instance = options[:instance]
|
4
5
|
end
|
5
6
|
|
6
|
-
def
|
7
|
-
|
7
|
+
def instances
|
8
|
+
[@instance].compact
|
8
9
|
end
|
9
10
|
|
10
|
-
def
|
11
|
-
|
12
|
-
|
11
|
+
def execute_instrumented(query)
|
12
|
+
create_transport_batch([self]).publish(true)
|
13
|
+
end
|
14
|
+
|
15
|
+
def increment_version_in_document
|
16
|
+
# No op
|
13
17
|
end
|
14
18
|
end
|