Skip to content

Commit 4c9b676

Browse files
scottwbavsej
authored andcommitted
Allow to retry Couchbase::Bucket#cas on collitions
Extend Couchbase::Bucket#cas to take a `:retry` Fixnum option that specifies the maximum number of times the method should retry the entire get/update/set operation when a Couchbase::Error::KeyExists error is encountered due to a concurrent update from another writer between its #get and #set calls. Change-Id: Ibf7ebac9c63e460e05957004458e9e02ae803f89 Reviewed-on: http://review.couchbase.org/30336 Reviewed-by: Sergey Avseyev <sergey.avseyev@gmail.com> Tested-by: Sergey Avseyev <sergey.avseyev@gmail.com>
1 parent d449c96 commit 4c9b676

File tree

2 files changed

+142
-4
lines changed

2 files changed

+142
-4
lines changed

Diff for: lib/couchbase/bucket.rb

+31-4
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,23 @@ class Bucket
3535
#
3636
# @see http://couchbase.com/docs/memcached-api/memcached-api-protocol-text_cas.html
3737
#
38+
# Setting the +:retry+ option to a positive number will cause this method
39+
# to rescue the {Couchbase::Error::KeyExists} error that happens when
40+
# an update collision is detected, and automatically get a fresh copy
41+
# of the value and retry the block. This will repeat as long as there
42+
# continues to be conflicts, up to the maximum number of retries specified.
43+
# For asynchronous mode, this means the block will be yielded once for
44+
# the initial {Bucket#get}, once for the final {Bucket#set} (successful
45+
# or last failure), and zero or more additional {Bucket#get} retries
46+
# in between, up to the maximum allowed by the +:retry+ option.
47+
#
3848
# @param [String, Symbol] key
3949
#
4050
# @param [Hash] options the options for "swap" part
4151
# @option options [Fixnum] :ttl (self.default_ttl) the time to live of this key
4252
# @option options [Symbol] :format (self.default_format) format of the value
4353
# @option options [Fixnum] :flags (self.default_flags) flags for this key
54+
# @option options [Fixnum] :retry (0) maximum number of times to autmatically retry upon update collision
4455
#
4556
# @yieldparam [Object, Result] value old value in synchronous mode and
4657
# +Result+ object in asynchronous mode.
@@ -80,16 +91,32 @@ class Bucket
8091
#
8192
# @return [Fixnum] the CAS of new value
8293
def cas(key, options = {})
94+
retries_remaining = options.delete(:retry) || 0
8395
if async?
8496
block = Proc.new
8597
get(key) do |ret|
8698
val = block.call(ret) # get new value from caller
87-
set(ret.key, val, options.merge(:cas => ret.cas, :flags => ret.flags), &block)
99+
set(ret.key, val, options.merge(:cas => ret.cas, :flags => ret.flags)) do |set_ret|
100+
if set_ret.error.is_a?(Couchbase::Error::KeyExists) && (retries_remaining > 0)
101+
cas(key, options.merge(:retry => retries_remaining - 1), &block)
102+
else
103+
block.call(set_ret)
104+
end
105+
end
88106
end
89107
else
90-
val, flags, ver = get(key, :extended => true)
91-
val = yield(val) # get new value from caller
92-
set(key, val, options.merge(:cas => ver, :flags => flags))
108+
begin
109+
val, flags, ver = get(key, :extended => true)
110+
val = yield(val) # get new value from caller
111+
set(key, val, options.merge(:cas => ver, :flags => flags))
112+
rescue Couchbase::Error::KeyExists
113+
if retries_remaining > 0
114+
retries_remaining -= 1
115+
retry
116+
else
117+
raise
118+
end
119+
end
93120
end
94121
end
95122
alias :compare_and_swap :cas

Diff for: test/test_cas.rb

+111
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,52 @@ def test_compare_and_swap_collision
5757
end
5858
end
5959

60+
def test_compare_and_swap_retry
61+
connection = Couchbase.new(:hostname => @mock.host, :port => @mock.port,
62+
:default_format => :document)
63+
connection.set(uniq_id, {"bar" => 1})
64+
calls = 0
65+
connection.cas(uniq_id, :retry => 1) do |val|
66+
calls += 1
67+
if calls == 1
68+
# Simulate collision with a separate writer. This will
69+
# change the CAS value to be different than what #cas just loaded.
70+
# Only do this the first time this block is executed.
71+
connection.set(uniq_id, {"bar" => 2})
72+
end
73+
74+
# Complete the modification we desire, which should fail when set.
75+
val["baz"] = 3
76+
val
77+
end
78+
assert_equal 2, calls
79+
val = connection.get(uniq_id)
80+
expected = {"bar" => 2, "baz" => 3}
81+
assert_equal expected, val
82+
end
83+
84+
def test_compare_and_swap_too_many_retries
85+
connection = Couchbase.new(:hostname => @mock.host, :port => @mock.port,
86+
:default_format => :document)
87+
connection.set(uniq_id, {"bar" => 0})
88+
calls = 0
89+
assert_raises(Couchbase::Error::KeyExists) do
90+
connection.cas(uniq_id, :retry => 10) do |val|
91+
calls += 1
92+
93+
# Simulate collision with a separate writer. This will
94+
# change the CAS value to be different than what #cas just loaded.
95+
# Do it every time so we just keep retrying and failing.
96+
connection.set(uniq_id, {"bar" => calls})
97+
98+
# Complete the modification we desire, which should fail when set.
99+
val["baz"] = 3
100+
val
101+
end
102+
end
103+
assert_equal 11, calls
104+
end
105+
60106
def test_compare_and_swap_async
61107
connection = Couchbase.new(:hostname => @mock.host, :port => @mock.port,
62108
:default_format => :document)
@@ -113,6 +159,71 @@ def test_compare_and_swap_async_collision
113159
assert_equal 2, calls
114160
end
115161

162+
def test_compare_and_swap_async_retry
163+
connection = Couchbase.new(:hostname => @mock.host, :port => @mock.port,
164+
:default_format => :document)
165+
connection.set(uniq_id, {"bar" => 1})
166+
calls = 0
167+
connection.run do |conn|
168+
conn.cas(uniq_id, :retry => 1) do |ret|
169+
calls += 1
170+
case ret.operation
171+
when :get
172+
new_val = ret.value
173+
174+
if calls == 1
175+
# Simulate collision with a separate writer. This will
176+
# change the CAS value to be different than what #cas just loaded.
177+
# Only do this the first time this block is executed.
178+
connection.set(uniq_id, {"bar" => 2})
179+
end
180+
181+
# Complete the modification we desire, which should fail when set.
182+
new_val["baz"] = 3
183+
new_val
184+
when :set
185+
assert ret.success?
186+
else
187+
flunk "Unexpected operation: #{ret.operation.inspect}"
188+
end
189+
end
190+
end
191+
assert_equal 3, calls
192+
val = connection.get(uniq_id)
193+
expected = {"bar" => 2, "baz" => 3}
194+
assert_equal expected, val
195+
end
196+
197+
def test_compare_and_swap_async_too_many_retries
198+
connection = Couchbase.new(:hostname => @mock.host, :port => @mock.port,
199+
:default_format => :document)
200+
connection.set(uniq_id, {"bar" => 0})
201+
calls = 0
202+
connection.run do |conn|
203+
conn.cas(uniq_id, :retry => 10) do |ret|
204+
calls += 1
205+
case ret.operation
206+
when :get
207+
new_val = ret.value
208+
209+
# Simulate collision with a separate writer. This will
210+
# change the CAS value to be different than what #cas just loaded.
211+
# Do it every time so we just keep retrying and failing.
212+
connection.set(uniq_id, {"bar" => calls})
213+
214+
# Complete the modification we desire, which should fail when set.
215+
new_val["baz"] = 3
216+
new_val
217+
when :set
218+
assert ret.error.is_a? Couchbase::Error::KeyExists
219+
else
220+
flunk "Unexpected operation: #{ret.operation.inspect}"
221+
end
222+
end
223+
end
224+
assert_equal 12, calls
225+
end
226+
116227
def test_flags_replication
117228
connection = Couchbase.new(:hostname => @mock.host, :port => @mock.port,
118229
:default_format => :document)

0 commit comments

Comments
 (0)