Skip to content

Commit fad3e42

Browse files
authored
fix: use thread local variables instead of relying on GVL for thread safety (#128)
1 parent 12e8880 commit fad3e42

File tree

5 files changed

+108
-45
lines changed

5 files changed

+108
-45
lines changed

lib/redis_client/cluster/node.rb

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -39,27 +39,36 @@ def build_connection_prelude
3939
class << self
4040
def load_info(options, **kwargs) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
4141
startup_size = options.size > MAX_STARTUP_SAMPLE ? MAX_STARTUP_SAMPLE : options.size
42-
node_info_list = Array.new(startup_size)
43-
errors = Array.new(startup_size)
42+
node_info_list = errors = nil
4443
startup_options = options.to_a.sample(MAX_STARTUP_SAMPLE).to_h
4544
startup_nodes = ::RedisClient::Cluster::Node.new(startup_options, **kwargs)
4645
startup_nodes.each_slice(MAX_THREADS).with_index do |chuncked_startup_nodes, chuncked_idx|
4746
threads = chuncked_startup_nodes.each_with_index.map do |raw_client, idx|
4847
Thread.new(raw_client, (MAX_THREADS * chuncked_idx) + idx) do |cli, i|
4948
Thread.pass
49+
Thread.current.thread_variable_set(:index, i)
5050
reply = cli.call('CLUSTER', 'NODES')
51-
node_info_list[i] = parse_node_info(reply)
51+
Thread.current.thread_variable_set(:info, parse_node_info(reply))
5252
rescue StandardError => e
53-
errors[i] = e
53+
Thread.current.thread_variable_set(:error, e)
5454
ensure
5555
cli&.close
5656
end
5757
end
5858

59-
threads.each(&:join)
59+
threads.each do |t|
60+
t.join
61+
if t.thread_variable?(:info)
62+
node_info_list ||= Array.new(startup_size)
63+
node_info_list[t.thread_variable_get(:index)] = t.thread_variable_get(:info)
64+
elsif t.thread_variable?(:error)
65+
errors ||= Array.new(startup_size)
66+
errors[t.thread_variable_get(:index)] = t.thread_variable_get(:error)
67+
end
68+
end
6069
end
6170

62-
raise ::RedisClient::Cluster::InitialSetupError, errors if node_info_list.all?(&:nil?)
71+
raise ::RedisClient::Cluster::InitialSetupError, errors if node_info_list.nil?
6372

6473
grouped = node_info_list.compact.group_by do |rows|
6574
rows.sort_by { |row| row[:id] }
@@ -147,7 +156,7 @@ def call_replicas(method, command, args, &block)
147156

148157
def send_ping(method, command, args, &block)
149158
result_values, errors = call_multiple_nodes(@topology.clients, method, command, args, &block)
150-
return result_values if errors.empty?
159+
return result_values if errors.nil? || errors.empty?
151160

152161
raise ReloadNeeded if errors.values.any?(::RedisClient::ConnectionError)
153162

@@ -228,26 +237,35 @@ def call_multiple_nodes(clients, method, command, args, &block)
228237

229238
def call_multiple_nodes!(clients, method, command, args, &block)
230239
result_values, errors = call_multiple_nodes(clients, method, command, args, &block)
231-
return result_values if errors.empty?
240+
return result_values if errors.nil? || errors.empty?
232241

233242
raise ::RedisClient::Cluster::ErrorCollection, errors
234243
end
235244

236-
def try_map(clients) # rubocop:disable Metrics/MethodLength
237-
results = {}
238-
errors = {}
245+
def try_map(clients) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
246+
results = errors = nil
239247
clients.each_slice(MAX_THREADS) do |chuncked_clients|
240248
threads = chuncked_clients.map do |k, v|
241249
Thread.new(k, v) do |node_key, client|
242250
Thread.pass
251+
Thread.current.thread_variable_set(:node_key, node_key)
243252
reply = yield(node_key, client)
244-
results[node_key] = reply unless reply.nil?
253+
Thread.current.thread_variable_set(:result, reply)
245254
rescue StandardError => e
246-
errors[node_key] = e
255+
Thread.current.thread_variable_set(:error, e)
247256
end
248257
end
249258

250-
threads.each(&:join)
259+
threads.each do |t|
260+
t.join
261+
if t.thread_variable?(:result)
262+
results ||= {}
263+
results[t.thread_variable_get(:node_key)] = t.thread_variable_get(:result)
264+
elsif t.thread_variable?(:error)
265+
errors ||= {}
266+
errors[t.thread_variable_get(:node_key)] = t.thread_variable_get(:error)
267+
end
268+
end
251269
end
252270

253271
[results, errors]

lib/redis_client/cluster/node/latency_replica.rb

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,12 @@ def any_replica_node_key(seed: nil)
4040
private
4141

4242
def measure_latencies(clients) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
43-
latencies = {}
44-
43+
latencies = nil
4544
clients.each_slice(::RedisClient::Cluster::Node::MAX_THREADS) do |chuncked_clients|
4645
threads = chuncked_clients.map do |k, v|
4746
Thread.new(k, v) do |node_key, client|
4847
Thread.pass
48+
Thread.current.thread_variable_set(:node_key, node_key)
4949

5050
min = DUMMY_LATENCY_NSEC
5151
MEASURE_ATTEMPT_COUNT.times do
@@ -55,13 +55,17 @@ def measure_latencies(clients) # rubocop:disable Metrics/MethodLength, Metrics/A
5555
min = duration if duration < min
5656
end
5757

58-
latencies[node_key] = min
58+
Thread.current.thread_variable_set(:latency, min)
5959
rescue StandardError
60-
latencies[node_key] = DUMMY_LATENCY_NSEC
60+
Thread.current.thread_variable_set(:latency, DUMMY_LATENCY_NSEC)
6161
end
6262
end
6363

64-
threads.each(&:join)
64+
threads.each do |t|
65+
t.join
66+
latencies ||= {}
67+
latencies[t.thread_variable_get(:node_key)] = t.thread_variable_get(:latency)
68+
end
6569
end
6670

6771
latencies

lib/redis_client/cluster/pipeline.rb

Lines changed: 36 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -20,37 +20,37 @@ def initialize(router, command_builder, seed: Random.new_seed)
2020
def call(*args, **kwargs, &block)
2121
command = @command_builder.generate(args, kwargs)
2222
node_key = @router.find_node_key(command, seed: @seed)
23-
add_line(node_key, [@size, :call_v, command, block])
23+
add_row(node_key, [@size, :call_v, command, block])
2424
end
2525

2626
def call_v(args, &block)
2727
command = @command_builder.generate(args)
2828
node_key = @router.find_node_key(command, seed: @seed)
29-
add_line(node_key, [@size, :call_v, command, block])
29+
add_row(node_key, [@size, :call_v, command, block])
3030
end
3131

3232
def call_once(*args, **kwargs, &block)
3333
command = @command_builder.generate(args, kwargs)
3434
node_key = @router.find_node_key(command, seed: @seed)
35-
add_line(node_key, [@size, :call_once_v, command, block])
35+
add_row(node_key, [@size, :call_once_v, command, block])
3636
end
3737

3838
def call_once_v(args, &block)
3939
command = @command_builder.generate(args)
4040
node_key = @router.find_node_key(command, seed: @seed)
41-
add_line(node_key, [@size, :call_once_v, command, block])
41+
add_row(node_key, [@size, :call_once_v, command, block])
4242
end
4343

4444
def blocking_call(timeout, *args, **kwargs, &block)
4545
command = @command_builder.generate(args, kwargs)
4646
node_key = @router.find_node_key(command, seed: @seed)
47-
add_line(node_key, [@size, :blocking_call_v, timeout, command, block])
47+
add_row(node_key, [@size, :blocking_call_v, timeout, command, block])
4848
end
4949

5050
def blocking_call_v(timeout, args, &block)
5151
command = @command_builder.generate(args)
5252
node_key = @router.find_node_key(command, seed: @seed)
53-
add_line(node_key, [@size, :blocking_call_v, timeout, command, block])
53+
add_row(node_key, [@size, :blocking_call_v, timeout, command, block])
5454
end
5555

5656
def empty?
@@ -59,44 +59,57 @@ def empty?
5959

6060
# TODO: https://github.com/redis-rb/redis-cluster-client/issues/37 handle redirections
6161
def execute # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
62-
all_replies = Array.new(@size)
63-
errors = {}
62+
all_replies = errors = nil
6463
@grouped.each_slice(MAX_THREADS) do |chuncked_grouped|
6564
threads = chuncked_grouped.map do |k, v|
6665
Thread.new(@router, k, v) do |router, node_key, rows|
6766
Thread.pass
68-
replies = router.find_node(node_key).pipelined do |pipeline|
69-
rows.each do |row|
70-
case row.size
71-
when 4 then pipeline.send(row[1], row[2], &row[3])
72-
when 5 then pipeline.send(row[1], row[2], row[3], &row[4])
73-
end
74-
end
75-
end
76-
67+
replies = do_pipelining(router.find_node(node_key), rows)
7768
raise ReplySizeError, "commands: #{rows.size}, replies: #{replies.size}" if rows.size != replies.size
7869

79-
rows.each_with_index { |row, idx| all_replies[row.first] = replies[idx] }
70+
Thread.current.thread_variable_set(:rows, rows)
71+
Thread.current.thread_variable_set(:replies, replies)
8072
rescue StandardError => e
81-
errors[node_key] = e
73+
Thread.current.thread_variable_set(:node_key, node_key)
74+
Thread.current.thread_variable_set(:error, e)
8275
end
8376
end
8477

85-
threads.each(&:join)
78+
threads.each do |t|
79+
t.join
80+
if t.thread_variable?(:replies)
81+
all_replies ||= Array.new(@size)
82+
t.thread_variable_get(:rows).each_with_index { |r, i| all_replies[r.first] = t.thread_variable_get(:replies)[i] }
83+
elsif t.thread_variable?(:error)
84+
errors ||= {}
85+
errors[t.thread_variable_get(:node_key)] = t.thread_variable_get(:error)
86+
end
87+
end
8688
end
8789

88-
return all_replies if errors.empty?
90+
return all_replies if errors.nil?
8991

9092
raise ::RedisClient::Cluster::ErrorCollection, errors
9193
end
9294

9395
private
9496

95-
def add_line(node_key, line)
97+
def add_row(node_key, row)
9698
@grouped[node_key] = [] unless @grouped.key?(node_key)
97-
@grouped[node_key] << line
99+
@grouped[node_key] << row
98100
@size += 1
99101
end
102+
103+
def do_pipelining(node, rows)
104+
node.pipelined do |pipeline|
105+
rows.each do |row|
106+
case row.size
107+
when 4 then pipeline.send(row[1], row[2], &row[3])
108+
when 5 then pipeline.send(row[1], row[2], row[3], &row[4])
109+
end
110+
end
111+
end
112+
end
100113
end
101114
end
102115
end

test/redis_client/cluster/test_normalized_cmd_name.rb

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,17 +67,22 @@ def test_thread_safety
6767
threads = results.each_with_index.map do |_, i|
6868
Thread.new do
6969
Thread.pass
70+
Thread.current.thread_variable_set(:index, i)
7071
if i.even?
71-
results[i] = @subject.get_by_command(%w[SET foo bar]) == 'set'
72+
Thread.current.thread_variable_set(:result, @subject.get_by_command(%w[SET foo bar]) == 'set')
7273
else
7374
@subject.clear
7475
end
7576
rescue StandardError
76-
results[i] = false
77+
Thread.current.thread_variable_set(:result, false)
7778
end
7879
end
7980

80-
threads.each(&:join)
81+
threads.each do |t|
82+
t.join
83+
results[t.thread_variable_get(:index)] = t.thread_variable_get(:result)
84+
end
85+
8186
refute_includes(results, false)
8287
end
8388
end

test/redis_client/test_cluster.rb

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,29 @@ def test_pipelined
139139
assert_equal(want, got)
140140
end
141141

142+
def test_pipelined_with_errors
143+
assert_raises(::RedisClient::Cluster::ErrorCollection) do
144+
@client.pipelined do |pipeline|
145+
10.times do |i|
146+
pipeline.call('SET', "string#{i}", i)
147+
pipeline.call('SET', "string#{i}", i, 'too many args')
148+
pipeline.call('SET', "string#{i}", i + 10)
149+
end
150+
end
151+
end
152+
153+
wait_for_replication
154+
155+
10.times { |i| assert_equal((i + 10).to_s, @client.call('GET', "string#{i}")) }
156+
end
157+
158+
def test_pipelined_with_many_commands
159+
@client.pipelined { |pi| 1000.times { |i| pi.call('SET', i, i) } }
160+
wait_for_replication
161+
results = @client.pipelined { |pi| 1000.times { |i| pi.call('GET', i) } }
162+
results.each_with_index { |got, i| assert_equal(i.to_s, got) }
163+
end
164+
142165
def test_pubsub
143166
10.times do |i|
144167
pubsub = @client.pubsub

0 commit comments

Comments
 (0)