Skip to content

Commit 427fc6a

Browse files
committed
fix: allow read-only operations when all masters are down
Read-only operations (get, select, count, min, max) used to fail with connection errors if all masters in the cluster were unavailable, even when healthy replicas were up and failover hadn't processed yet. To resolve this, the following improvements were made: - Introduced a `read_only` flag to `utils.get_space[s]` to fetch cluster schema from any healthy replica if masters are down. - Updated `get`, `select`, `count`, `min`, `max` to use this new flag. - Rewrote `call.any` to iterate through all replicasets and utilize vshard's `callro` instead of `call` to fetch metadata from replicas.
1 parent b9720b3 commit 427fc6a

10 files changed

Lines changed: 178 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
77

88
## [Unreleased]
99

10+
### Fixed
11+
* Allow read-only operations (`get`, `select`, `count`, `min`, `max`) to execute
12+
successfully using healthy replicas when all cluster masters are down.
13+
1014
## [1.7.4] - 12-02-26
1115

1216
### Fixed

crud/borders.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ local function call_get_border_on_router(vshard_router, border_name, space_name,
7272
fetch_latest_metadata = '?boolean',
7373
})
7474

75-
local space, err, netbox_schema_version = utils.get_space(space_name, vshard_router, opts.timeout)
75+
local space, err, netbox_schema_version = utils.get_space(space_name, vshard_router, opts.timeout, nil, true)
7676
if err ~= nil then
7777
return nil, BorderError:new("An error occurred during the operation: %s", err), const.NEED_SCHEMA_RELOAD
7878
end

crud/common/call.lua

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -270,19 +270,26 @@ function call.any(vshard_router, func_name, func_args, opts)
270270
if replicasets == nil then
271271
return nil, CallError:new("Failed to get router replicasets: %s", err.err)
272272
end
273-
local replicaset_id, replicaset = next(replicasets)
274273

275-
local res, err = call_with_retry_and_recovery(vshard_router, replicaset, 'call',
276-
func_name, func_args, {timeout = timeout}, false)
277-
if err ~= nil then
278-
return nil, wrap_vshard_err(vshard_router, err, func_name, replicaset_id)
279-
end
274+
local last_replicaset_id = nil
275+
local last_err = nil
280276

281-
if res == box.NULL then
282-
return nil
277+
for replicaset_id, replicaset in pairs(replicasets) do
278+
local res, err = call_with_retry_and_recovery(vshard_router, replicaset, 'callro',
279+
func_name, func_args, {timeout = timeout}, false)
280+
281+
if err == nil then
282+
if res == box.NULL then
283+
return nil
284+
end
285+
return res
286+
end
287+
288+
last_replicaset_id = replicaset_id
289+
last_err = err
283290
end
284291

285-
return res
292+
return nil, wrap_vshard_err(vshard_router, last_err, func_name, last_replicaset_id)
286293
end
287294

288295
return call

crud/common/utils.lua

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -123,8 +123,19 @@ local function get_replicaset_by_replica_id(replicasets, id)
123123
return nil, nil
124124
end
125125

126-
function utils.get_spaces(vshard_router, timeout, replica_id)
127-
local replicasets, replicaset, replicaset_id, master
126+
local function find_any_healthy_replica_conn(replicasets)
127+
for _, replicaset in pairs(replicasets) do
128+
for _, replica in pairs(replicaset.replicas) do
129+
if replica.conn ~= nil and replica.conn.error == nil then
130+
return replica.conn
131+
end
132+
end
133+
end
134+
return nil
135+
end
136+
137+
function utils.get_spaces(vshard_router, timeout, replica_id, read_only)
138+
local replicasets, replicaset, replicaset_id, master, ro_replica_conn
128139

129140
timeout = timeout or const.DEFAULT_VSHARD_CALL_TIMEOUT
130141
local deadline = fiber.clock() + timeout
@@ -155,9 +166,22 @@ function utils.get_spaces(vshard_router, timeout, replica_id)
155166
end
156167
end
157168

169+
-- If the master check above didn't succeed (or master is dead),
170+
-- and this is a read-only operation, try to find any available healthy replica.
171+
if read_only then
172+
ro_replica_conn = find_any_healthy_replica_conn(replicasets)
173+
if ro_replica_conn ~= nil then
174+
break
175+
end
176+
end
177+
158178
fiber.sleep(iter_sleep)
159179
end
160180

181+
if read_only and ro_replica_conn ~= nil then
182+
return ro_replica_conn.space, nil, ro_replica_conn.schema_version
183+
end
184+
161185
if replicaset == nil then
162186
return nil, GetSpaceError:new(
163187
'The router returned empty replicasets: ' ..
@@ -184,8 +208,8 @@ function utils.get_spaces(vshard_router, timeout, replica_id)
184208
return master.conn.space, nil, master.conn.schema_version
185209
end
186210

187-
function utils.get_space(space_name, vshard_router, timeout, replica_id)
188-
local spaces, err, schema_version = utils.get_spaces(vshard_router, timeout, replica_id)
211+
function utils.get_space(space_name, vshard_router, timeout, replica_id, read_only)
212+
local spaces, err, schema_version = utils.get_spaces(vshard_router, timeout, replica_id, read_only)
189213

190214
if spaces == nil then
191215
return nil, err

crud/count.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ local function call_count_on_router(vshard_router, space_name, user_conditions,
131131
return nil, CountError:new("Failed to parse conditions: %s", err)
132132
end
133133

134-
local space, err = utils.get_space(space_name, vshard_router, opts.timeout)
134+
local space, err = utils.get_space(space_name, vshard_router, opts.timeout, nil, true)
135135
if err ~= nil then
136136
return nil, CountError:new("An error occurred during the operation: %s", err), const.NEED_SCHEMA_RELOAD
137137
end

crud/get.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ local function call_get_on_router(vshard_router, space_name, key, opts)
8282
fetch_latest_metadata = '?boolean',
8383
})
8484

85-
local space, err, netbox_schema_version = utils.get_space(space_name, vshard_router, opts.timeout)
85+
local space, err, netbox_schema_version = utils.get_space(space_name, vshard_router, opts.timeout, nil, true)
8686
if err ~= nil then
8787
return nil, GetError:new("An error occurred during the operation: %s", err), const.NEED_SCHEMA_RELOAD
8888
end

crud/select/compat/select.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ local function build_select_iterator(vshard_router, space_name, user_conditions,
5252
return nil, SelectError:new("Failed to parse conditions: %s", err)
5353
end
5454

55-
local space, err, netbox_schema_version = utils.get_space(space_name, vshard_router)
55+
local space, err, netbox_schema_version = utils.get_space(space_name, vshard_router, nil, nil, true)
5656
if err ~= nil then
5757
return nil, SelectError:new("An error occurred during the operation: %s", err), const.NEED_SCHEMA_RELOAD
5858
end

test/integration/read_calls_strategies_test.lua

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ pgroup.before_all(function(g)
5252
return vshard_call_strategies
5353
end
5454

55+
-- Warm up router sharding metadata cache.
56+
-- This ensures that internal callro via call.any won't fire
57+
-- unpredictably during actual test execution.
58+
g.router:call('crud.get', {'customers', 1})
59+
5560
-- patch vshard.router.call* functions
5661
local vshard_call_names = {'callro', 'callbro', 'callre', 'callbre', 'callrw'}
5762
g.router:call('patch_vshard_calls', {vshard_call_names})
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
local t = require('luatest')
2+
3+
local helpers = require('test.helper')
4+
5+
local pgroup = t.group('read_only_with_dead_masters', helpers.backend_matrix({
6+
{engine = 'memtx'},
7+
}))
8+
9+
pgroup.before_all(function(g)
10+
helpers.start_default_cluster(g, 'srv_simple_operations')
11+
end)
12+
13+
pgroup.after_all(function(g)
14+
helpers.stop_cluster(g.cluster, g.params.backend)
15+
end)
16+
17+
pgroup.test_read_operations_when_masters_are_down = function(g)
18+
local insert_res, err = g.router:call('crud.insert', {'customers', {1, box.NULL, 'Elizabeth', 12}})
19+
t.assert_equals(err, nil)
20+
t.assert_not_equals(insert_res, nil)
21+
22+
insert_res, err = g.router:call('crud.insert', {'customers', {5, box.NULL, 'Jack', 35}})
23+
t.assert_equals(err, nil)
24+
t.assert_not_equals(insert_res, nil)
25+
26+
-- Stop all masters in the cluster to simulate total master outage.
27+
local s1_master = g.cluster:server('s1-master')
28+
local s2_master = g.cluster:server('s2-master')
29+
30+
s1_master:stop()
31+
s2_master:stop()
32+
33+
local select_res, err = g.router:call('crud.select', {'customers', {{'<=', 'age', 35}}})
34+
t.assert_equals(err, nil)
35+
t.assert_not_equals(select_res, nil)
36+
t.assert_equals(#select_res.rows, 2)
37+
38+
local get_res, err = g.router:call('crud.get', {'customers', 1})
39+
t.assert_equals(err, nil)
40+
t.assert_not_equals(get_res, nil)
41+
t.assert_equals(get_res.rows[1][3], 'Elizabeth')
42+
43+
local count_res, err = g.router:call('crud.count', {'customers', {{'==', 'age', 35}}})
44+
t.assert_equals(err, nil)
45+
t.assert_equals(count_res, 1)
46+
47+
local min_res, err = g.router:call('crud.min', {'customers'})
48+
t.assert_equals(err, nil)
49+
t.assert_not_equals(min_res, nil)
50+
t.assert_equals(min_res.rows[1][1], 1)
51+
52+
local max_res, err = g.router:call('crud.max', {'customers'})
53+
t.assert_equals(err, nil)
54+
t.assert_not_equals(max_res, nil)
55+
t.assert_equals(max_res.rows[1][1], 5)
56+
end

test/unit/call_test.lua

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,8 +237,11 @@ pgroup.test_any_vshard_call = function(g)
237237
return call.any(vshard.router.static, 'say_hi_politely', {'dude'}, {})
238238
]])
239239

240-
t.assert_equals(results, 'HI, dude! I am 1')
240+
t.assert_str_contains(results, 'HI, dude!')
241241
t.assert_equals(err, nil)
242+
243+
local vshard_calls = g.get_vshard_calls()
244+
t.assert_equals(vshard_calls, {'callro'})
242245
end
243246

244247
pgroup.test_any_vshard_call_timeout = function(g)
@@ -264,3 +267,64 @@ pgroup.test_any_vshard_call_timeout = function(g)
264267
helpers.assert_str_contains_pattern_with_replicaset_id(err.err, "Failed for [replicaset_id]")
265268
helpers.assert_timeout_error(err.err)
266269
end
270+
271+
pgroup.before_test('test_any_vshard_call_fallback', function(g)
272+
-- Mock callro to fail exactly once per replicaset.
273+
-- This guarantees that call.any triggers its fallback loop.
274+
g.router:eval([[
275+
local vshard = require('vshard')
276+
local errors = require('errors')
277+
278+
local replicasets = vshard.router.static:routeall()
279+
280+
rawset(_G, '__original_callros', {})
281+
local originals = rawget(_G, '__original_callros')
282+
283+
for replicaset_id, replicaset in pairs(replicasets) do
284+
originals[replicaset_id] = replicaset.callro
285+
286+
local calls_count = 0
287+
288+
replicaset.callro = function(self, ...)
289+
calls_count = calls_count + 1
290+
if calls_count == 1 then
291+
return nil, errors.new_class('ClientError'):new('Temporary failure')
292+
else
293+
return originals[replicaset_id](self, ...)
294+
end
295+
end
296+
end
297+
]])
298+
end)
299+
300+
pgroup.after_test('test_any_vshard_call_fallback', function(g)
301+
g.router:eval([[
302+
local vshard = require('vshard')
303+
304+
local replicasets = vshard.router.static:routeall()
305+
local originals = rawget(_G, '__original_callros')
306+
307+
for replicaset_id, original_fun in pairs(originals) do
308+
if replicasets[replicaset_id] ~= nil then
309+
replicasets[replicaset_id].callro = original_fun
310+
end
311+
end
312+
rawset(_G, '__original_callros', nil)
313+
]])
314+
end)
315+
316+
pgroup.test_any_vshard_call_fallback = function(g)
317+
g.clear_vshard_calls()
318+
319+
local results, err = g.router:eval([[
320+
local vshard = require('vshard')
321+
local call = require('crud.common.call')
322+
323+
return call.any(vshard.router.static, 'say_hi_politely', {'survivor'}, {})
324+
]])
325+
326+
-- Ensure call.any successfully routes around the initial failure
327+
-- and uses the available fallback.
328+
t.assert_str_contains(results, 'HI, survivor!')
329+
t.assert_equals(err, nil)
330+
end

0 commit comments

Comments
 (0)