legion-cache 1.4.2 → 1.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'openssl'
4
4
  require 'redis'
5
+ require 'uri'
5
6
  require 'legion/logging/helper'
6
7
  require 'legion/cache/pool'
7
8
  require 'legion/cache/settings'
@@ -13,67 +14,69 @@ module Legion
13
14
  extend self
14
15
  extend Legion::Logging::Helper
15
16
 
16
- def client(server: nil, servers: [], pool_size: nil, timeout: nil, # rubocop:disable Metrics/ParameterLists
17
- username: nil, password: nil, logger: nil, **opts)
17
+ def client(**opts)
18
18
  return @client unless @client.nil?
19
19
 
20
- settings = defined?(Legion::Settings) ? Legion::Settings[:cache] : {}
21
- pool_size ||= settings[:pool_size] || 10
22
- timeout ||= settings[:timeout] || 5
23
-
24
- cluster = opts.delete(:cluster)
25
- replica = opts.delete(:replica) || false
26
- fixed_hostname = opts.delete(:fixed_hostname)
27
- db = opts.delete(:db)
28
- reconnect_attempts = opts.delete(:reconnect_attempts) || [0, 0.5, 1]
29
-
30
- @pool_size = pool_size
31
- @timeout = timeout
32
- @cluster_mode = Array(cluster).compact.any?
33
- @component_logger = logger || log
34
-
35
- @connection_opts = { username: username, password: password, timeout: @timeout }.compact
36
- @connection_opts.merge!(redis_tls_options(port: resolve_primary_port(server: server, servers: servers, cluster: cluster)))
37
-
38
- checkout_timeout = opts[:pool_checkout_timeout] || settings[:pool_checkout_timeout] || @timeout
39
- @client = ConnectionPool.new(size: pool_size, timeout: checkout_timeout) do
40
- build_redis_client(server: server, servers: servers, cluster: cluster,
41
- replica: replica, fixed_hostname: fixed_hostname,
42
- username: username, password: password, db: db,
43
- reconnect_attempts: reconnect_attempts)
20
+ settings = redis_settings.merge(opts)
21
+ if opts.key?(:server) || opts.key?(:servers)
22
+ settings[:server] = opts[:server]
23
+ settings[:servers] = opts.fetch(:servers, [])
24
+ settings[:cluster] = nil unless opts.key?(:cluster)
44
25
  end
45
- @connected = true
46
- log.info "Redis connected to #{resolved_redis_address(server: server, servers: servers, cluster: cluster)}"
26
+ @redis_settings = settings
27
+ @pool_size = settings[:pool_size] || 10
28
+ @timeout = settings[:timeout] || 5
29
+ raise ArgumentError, 'Redis timeout must be positive' unless @timeout.positive?
30
+
31
+ @cluster_mode = Array(settings[:cluster]).compact.any?
32
+ @component_logger = settings[:logger] || log
33
+ checkout_timeout = settings[:pool_checkout_timeout] || @timeout
34
+ pending_pool = ConnectionPool.new(size: @pool_size, timeout: checkout_timeout) do
35
+ build_redis_client(**settings.slice(:server, :servers, :cluster, :replica, :fixed_hostname,
36
+ :username, :password, :db, :reconnect_attempts))
37
+ end
38
+ pending_pool.with do |conn|
39
+ raise ::Redis::ConnectionError, 'Redis health check failed' unless conn.ping == 'PONG'
40
+ end
41
+ @client = pending_pool
42
+ cache_mark_connected(true)
47
43
  @client
48
44
  rescue StandardError => e
49
- handle_exception(e, level: :error, handled: false, operation: :redis_client,
50
- server: server, servers: Array(servers), cluster_nodes: Array(cluster))
51
- @connected = false
45
+ cache_mark_connected(false)
46
+ @client = nil
47
+ begin
48
+ pending_pool&.shutdown(&:close)
49
+ rescue StandardError => close_error
50
+ handle_exception(close_error, level: :warn, handled: true, operation: :redis_failed_pool_close)
51
+ end
52
+ handle_exception(e, level: :error, handled: false, operation: :redis_client)
52
53
  raise
53
54
  end
54
55
 
55
56
  def build_redis_client(server: nil, servers: [], cluster: nil, replica: false, fixed_hostname: nil, # rubocop:disable Metrics/ParameterLists
56
57
  username: nil, password: nil, db: nil, reconnect_attempts: [0, 0.5, 1])
57
58
  nodes = Array(cluster).compact
59
+ options = { username: username, password: password, db: db, timeout: @timeout || 5,
60
+ reconnect_attempts: reconnect_attempts }.compact
58
61
  if nodes.any?
59
- opts = { cluster: nodes, reconnect_attempts: reconnect_attempts, timeout: @timeout }
60
- opts[:replica] = true if replica
61
- opts[:fixed_hostname] = fixed_hostname unless fixed_hostname.nil?
62
- opts[:username] = username unless username.nil?
63
- opts[:password] = password unless password.nil?
64
- ::Redis.new(**opts)
62
+ require 'redis-clustering'
63
+ raise ArgumentError, 'Redis Cluster only supports database 0' unless db.nil? || db.to_i.zero?
64
+
65
+ options.merge!(redis_tls_options(port: resolve_primary_port(cluster: nodes)))
66
+ options[:replica] = replica
67
+ options[:fixed_hostname] = fixed_hostname unless fixed_hostname.nil?
68
+ ::Redis::Cluster.new(nodes: nodes, **options)
65
69
  else
66
- resolved = Legion::Cache::Settings.resolve_servers(
67
- driver: 'redis', server: server, servers: servers
68
- )
69
- host, port = Legion::Cache::Settings.parse_server_address(resolved.first, default_port: 6379)
70
- redis_opts = { host: host, port: port.to_i, reconnect_attempts: reconnect_attempts,
71
- timeout: @timeout }
72
- redis_opts[:username] = username unless username.nil?
73
- redis_opts[:password] = password unless password.nil?
74
- redis_opts[:db] = db unless db.nil?
75
- redis_opts.merge!(redis_tls_options(port: port.to_i))
76
- ::Redis.new(**redis_opts)
70
+ address = (Array(servers) + Array(server)).first
71
+ if address.to_s.start_with?('redis://', 'rediss://')
72
+ options[:url] = address
73
+ port = URI.parse(address).port || 6379
74
+ else
75
+ host, port = Legion::Cache::Settings.parse_server_address(address, default_port: 6379)
76
+ options.merge!(host: host, port: port)
77
+ end
78
+ options.merge!(redis_tls_options(port: port))
79
+ ::Redis.new(**options)
77
80
  end
78
81
  end
79
82
 
@@ -81,32 +84,45 @@ module Legion
81
84
  @cluster_mode == true
82
85
  end
83
86
 
87
+ def redis_key(key)
88
+ namespace = redis_settings[:namespace].to_s
89
+ namespace.empty? ? key.to_s : "#{namespace}:#{key}"
90
+ end
91
+
84
92
  def get(key)
85
- raw = client.with { |conn| conn.get(key) }
93
+ raw = client.with { |conn| conn.get(redis_key(key)) }
86
94
  result = deserialize_value(raw)
87
95
  log.debug { "[cache] GET #{key} hit=#{!result.nil?}" }
88
96
  result
89
97
  rescue StandardError => e
98
+ cache_backend_failed(e)
90
99
  handle_exception(e, level: :warn, handled: true, operation: :redis_get, key: key)
91
100
  nil
92
101
  end
93
102
 
94
103
  def fetch(key, ttl: nil)
104
+ redis_ttl(ttl)
95
105
  result = get(key)
96
106
  return result unless result.nil? && block_given?
97
107
 
98
108
  result = yield
99
- set(key, result, ttl: ttl)
109
+ begin
110
+ set(key, result, ttl: ttl)
111
+ rescue StandardError => e
112
+ handle_exception(e, level: :warn, handled: true, operation: :redis_fetch_store, key: key)
113
+ end
100
114
  result
101
115
  end
102
116
 
103
117
  def set_nx(key, value, ttl: nil)
104
- effective_ttl = ttl || default_ttl
118
+ effective_ttl = redis_ttl(ttl)
105
119
  serialized = serialize_value(value)
106
- result = client.with { |conn| conn.set(key, serialized, nx: true, ex: effective_ttl) == 'OK' }
120
+ args = effective_ttl ? { ex: effective_ttl } : {}
121
+ result = client.with { |conn| conn.set(redis_key(key), serialized, nx: true, **args) == 'OK' }
107
122
  log.debug { "[cache] SET_NX #{key} ttl=#{effective_ttl.inspect} result=#{result}" }
108
123
  result
109
124
  rescue StandardError => e
125
+ cache_backend_failed(e)
110
126
  handle_exception(e, level: :error, handled: false, operation: :redis_set_nx, key: key, ttl: effective_ttl)
111
127
  raise
112
128
  end
@@ -116,42 +132,77 @@ module Legion
116
132
  end
117
133
 
118
134
  def set_sync(key, value, ttl: nil, **)
119
- effective_ttl = ttl || default_ttl
135
+ effective_ttl = redis_ttl(ttl)
120
136
  args = {}
121
137
  args[:ex] = effective_ttl unless effective_ttl.nil?
122
138
  serialized = serialize_value(value)
123
- result = client.with { |conn| conn.set(key, serialized, **args) == 'OK' }
139
+ result = client.with { |conn| conn.set(redis_key(key), serialized, **args) == 'OK' }
124
140
  log.debug { "[cache] SET #{key} ttl=#{effective_ttl.inspect} success=#{result}" }
125
141
  result
126
142
  rescue StandardError => e
143
+ cache_backend_failed(e)
127
144
  handle_exception(e, level: :error, handled: false, operation: :redis_set_sync, key: key, ttl: effective_ttl)
128
145
  raise
129
146
  end
130
147
 
148
+ def cas(key, ttl: nil)
149
+ effective_ttl = redis_ttl(ttl)
150
+ args = effective_ttl ? { ex: effective_ttl } : {}
151
+ qualified = redis_key(key)
152
+ user_error = nil
153
+ client.with do |conn|
154
+ 10.times do
155
+ value = nil
156
+ committed = conn.watch(qualified) do |watched|
157
+ current = deserialize_value(watched.get(qualified))
158
+ begin
159
+ value = yield current
160
+ rescue StandardError => e
161
+ user_error = e
162
+ raise
163
+ end
164
+ serialized = serialize_value(value)
165
+ watched.multi { |transaction| transaction.set(qualified, serialized, **args) }
166
+ end
167
+ return value unless committed.nil?
168
+ end
169
+ raise ::Redis::CommandError, 'Redis CAS conflicted after 10 attempts'
170
+ end
171
+ rescue StandardError => e
172
+ raise if e.equal?(user_error)
173
+
174
+ cache_backend_failed(e)
175
+ handle_exception(e, level: :error, handled: false, operation: :redis_cas, key: key)
176
+ raise
177
+ end
178
+
131
179
  def delete(key, **)
132
180
  delete_sync(key)
133
181
  end
134
182
 
135
183
  def delete_sync(key)
136
- result = client.with { |conn| conn.del(key) == 1 }
184
+ result = client.with { |conn| conn.del(redis_key(key)) == 1 }
137
185
  log.debug { "[cache] DELETE #{key} success=#{result}" }
138
186
  result
139
187
  rescue StandardError => e
188
+ cache_backend_failed(e)
140
189
  handle_exception(e, level: :error, handled: false, operation: :redis_delete_sync, key: key)
141
190
  raise
142
191
  end
143
192
 
144
193
  def flush
145
194
  result = client.with do |conn|
146
- if cluster_mode?
147
- cluster_flush(conn)
148
- else
149
- conn.flushdb == 'OK'
150
- end
195
+ prefix = redis_key('')
196
+ raise ArgumentError, 'Redis flush requires a namespace' if prefix.empty?
197
+
198
+ pattern = prefix.gsub(/[\\*?\[\]]/) { |character| "\\#{character}" }
199
+ conn.scan_each(match: "#{pattern}*", count: 100) { |key| conn.del(key) }
200
+ true
151
201
  end
152
202
  log.debug { '[cache] FLUSH completed' }
153
203
  result
154
204
  rescue StandardError => e
205
+ cache_backend_failed(e)
155
206
  handle_exception(e, level: :warn, handled: true, operation: :redis_flush)
156
207
  nil
157
208
  end
@@ -160,18 +211,19 @@ module Legion
160
211
  keys = keys.flatten
161
212
  return {} if keys.empty?
162
213
 
163
- result = client.with do |conn|
214
+ qualified = keys.map { |key| redis_key(key) }
215
+ values = client.with do |conn|
164
216
  if cluster_mode?
165
- cluster_mget(conn, keys)
217
+ cluster_mget(conn, qualified).values_at(*qualified)
166
218
  else
167
- values = conn.mget(*keys)
168
- keys.zip(values).to_h
219
+ conn.mget(*qualified)
169
220
  end
170
221
  end
171
- result = result.transform_values { |v| deserialize_value(v) }
222
+ result = keys.zip(values.map { |value| deserialize_value(value) }).to_h
172
223
  log.debug { "[cache] MGET keys=#{keys.size}" }
173
224
  result
174
225
  rescue StandardError => e
226
+ cache_backend_failed(e)
175
227
  handle_exception(e, level: :warn, handled: true, operation: :redis_mget, key_count: keys.size)
176
228
  {}
177
229
  end
@@ -220,12 +272,16 @@ module Legion
220
272
  raw
221
273
  end
222
274
 
223
- def default_ttl
224
- return 3600 unless defined?(Legion::Settings)
275
+ def redis_settings
276
+ return @redis_settings if @redis_settings
277
+ return @cache_settings if @cache_settings
278
+
279
+ shared = defined?(Legion::Settings) ? Legion::Settings[:cache] : nil
280
+ Legion::Cache::Settings.default.merge(shared || {})
281
+ end
225
282
 
226
- Legion::Settings.dig(:cache, :default_ttl) || 3600
227
- rescue StandardError
228
- 3600
283
+ def default_ttl
284
+ redis_settings[:default_ttl]
229
285
  end
230
286
 
231
287
  def cluster_mget(conn, keys)
@@ -238,61 +294,34 @@ module Legion
238
294
  result
239
295
  end
240
296
 
241
- def cluster_mset(conn, hash)
242
- groups = group_keys_by_slot(hash.keys)
243
- groups.each_value do |group_keys|
244
- pairs = group_keys.flat_map { |k| [k, hash[k]] }
245
- conn.mset(*pairs)
246
- end
247
- true
248
- end
249
-
250
- def cluster_flush(conn)
251
- node_info = conn.cluster('nodes')
252
- primaries = node_info.lines.select { |l| l.include?('master') }.map { |l| l.split[1].split('@').first }
253
- primaries.each do |addr|
254
- host, port = Legion::Cache::Settings.parse_server_address(addr, default_port: 6379)
255
- node = ::Redis.new(host: host, port: port.to_i, **(@connection_opts || {}))
256
- node.flushdb
257
- node.close
258
- end
259
- true
260
- rescue StandardError => e
261
- handle_exception(e, level: :warn, handled: true, operation: :cluster_flush, fallback: :single_flushdb)
262
- conn.flushdb == 'OK'
263
- end
264
-
265
297
  def group_keys_by_slot(keys)
266
- if defined?(::Redis::Cluster::KeySlotConverter)
267
- keys.group_by { |k| ::Redis::Cluster::KeySlotConverter.convert(k) }
268
- else
269
- { 0 => keys }
270
- end
298
+ keys.group_by { |key| ::RedisClient::Cluster::KeySlotConverter.convert(key) }
271
299
  end
272
300
 
273
- def resolved_redis_address(server:, servers:, cluster:)
274
- nodes = Array(cluster).compact
275
- return nodes.join(', ') if nodes.any?
301
+ def redis_ttl(ttl)
302
+ value = ttl.nil? ? default_ttl : ttl
303
+ return nil if value.nil?
276
304
 
277
- Legion::Cache::Settings.resolve_servers(driver: 'redis', server: server, servers: Array(servers)).first
278
- rescue StandardError => e
279
- handle_exception(e, level: :warn, handled: true, operation: :resolved_redis_address)
280
- 'unknown'
305
+ raise ArgumentError, 'TTL must not be negative' if value.negative?
306
+
307
+ value.zero? ? nil : value
281
308
  end
282
309
 
283
310
  def resolve_primary_port(server: nil, servers: [], cluster: nil)
284
- nodes = Array(cluster).compact
285
- return 6379 if nodes.any?
311
+ address = Array(cluster).compact.first || (Array(servers) + Array(server)).first
312
+ return address.fetch(:port, 6379).to_i if address.is_a?(Hash)
313
+ return URI.parse(address).port || 6379 if address.to_s.start_with?('redis://', 'rediss://')
286
314
 
287
- resolved = Legion::Cache::Settings.resolve_servers(driver: 'redis', server: server, servers: Array(servers))
288
- _, port = Legion::Cache::Settings.parse_server_address(resolved.first, default_port: 6379)
289
- port.to_i
290
- rescue StandardError
291
- 6379
315
+ Legion::Cache::Settings.parse_server_address(address, default_port: 6379).last
292
316
  end
293
317
 
294
318
  def redis_tls_options(port:)
295
- return {} unless defined?(Legion::Crypt::TLS)
319
+ unless defined?(Legion::Crypt::TLS)
320
+ settings = cache_tls_settings
321
+ return {} if settings.empty? || settings[:enabled] == false
322
+
323
+ raise ArgumentError, 'Legion::Crypt::TLS is required for configured Redis TLS'
324
+ end
296
325
 
297
326
  tls = Legion::Crypt::TLS.resolve(cache_tls_settings, port: port)
298
327
  return {} unless tls[:enabled]
@@ -301,17 +330,14 @@ module Legion
301
330
  verify_mode: tls[:verify] == :none ? OpenSSL::SSL::VERIFY_NONE : OpenSSL::SSL::VERIFY_PEER
302
331
  }
303
332
  ssl_params[:ca_file] = tls[:ca] if tls[:ca]
333
+ ssl_params[:cert] = OpenSSL::X509::Certificate.new(File.read(tls[:cert])) if tls[:cert]
334
+ ssl_params[:key] = OpenSSL::PKey.read(File.read(tls[:key])) if tls[:key]
304
335
 
305
336
  { ssl: true, ssl_params: ssl_params }
306
337
  end
307
338
 
308
339
  def cache_tls_settings
309
- return {} unless defined?(Legion::Settings)
310
-
311
- Legion::Settings[:cache][:tls] || {}
312
- rescue StandardError => e
313
- handle_exception(e, level: :warn, handled: true, operation: :cache_tls_settings)
314
- {}
340
+ redis_settings[:tls] || {}
315
341
  end
316
342
  end
317
343
  end
@@ -28,7 +28,7 @@ module Legion
28
28
 
29
29
  Legion::Cache.pool.with do |conn|
30
30
  flat = hash.flat_map { |k, v| [k.to_s, v.to_s] }
31
- conn.hset(key, *flat)
31
+ conn.hset(Legion::Cache.redis_key(key), *flat)
32
32
  end
33
33
  log.debug "[cache:redis_hash] HSET #{key} fields=#{hash.size}"
34
34
  true
@@ -42,7 +42,7 @@ module Legion
42
42
  return nil unless redis_available?
43
43
 
44
44
  result = Legion::Cache.pool.with do |conn|
45
- conn.hgetall(key)
45
+ conn.hgetall(Legion::Cache.redis_key(key))
46
46
  end
47
47
  log.debug "[cache:redis_hash] HGETALL #{key} fields=#{result.size}"
48
48
  result
@@ -56,7 +56,7 @@ module Legion
56
56
  return 0 unless redis_available?
57
57
 
58
58
  result = Legion::Cache.pool.with do |conn|
59
- conn.hdel(key, *fields)
59
+ conn.hdel(Legion::Cache.redis_key(key), *fields)
60
60
  end
61
61
  log.debug "[cache:redis_hash] HDEL #{key} fields=#{fields.size} removed=#{result}"
62
62
  result
@@ -70,7 +70,7 @@ module Legion
70
70
  return false unless redis_available?
71
71
 
72
72
  Legion::Cache.pool.with do |conn|
73
- conn.zadd(key, score.to_f, member.to_s)
73
+ conn.zadd(Legion::Cache.redis_key(key), score.to_f, member.to_s)
74
74
  end
75
75
  log.debug "[cache:redis_hash] ZADD #{key} member=#{member}"
76
76
  true
@@ -88,7 +88,7 @@ module Legion
88
88
  opts[:limit] = limit if limit
89
89
 
90
90
  result = Legion::Cache.pool.with do |conn|
91
- conn.zrangebyscore(key, min, max, **opts)
91
+ conn.zrangebyscore(Legion::Cache.redis_key(key), min, max, **opts)
92
92
  end
93
93
  log.debug "[cache:redis_hash] ZRANGEBYSCORE #{key} results=#{result.size}"
94
94
  result
@@ -102,7 +102,7 @@ module Legion
102
102
  return false unless redis_available?
103
103
 
104
104
  Legion::Cache.pool.with do |conn|
105
- conn.zrem(key, member.to_s)
105
+ conn.zrem(Legion::Cache.redis_key(key), member.to_s)
106
106
  end
107
107
  log.debug "[cache:redis_hash] ZREM #{key} member=#{member}"
108
108
  true
@@ -113,19 +113,22 @@ module Legion
113
113
 
114
114
  # Set a TTL (in seconds) on a key.
115
115
  def expire(key, seconds)
116
+ raise ArgumentError, 'TTL must not be negative' if seconds.negative?
116
117
  return false unless redis_available?
117
118
 
118
- result = Legion::Cache.pool.with do |conn|
119
- conn.expire(key, seconds.to_i) == 1
119
+ begin
120
+ Legion::Cache.pool.with do |conn|
121
+ qualified = Legion::Cache.redis_key(key)
122
+ seconds.zero? ? conn.persist(qualified) : conn.expire(qualified, seconds)
123
+ end
124
+ rescue StandardError => e
125
+ log_redis_error('expire', e)
126
+ false
120
127
  end
121
- log.debug "[cache:redis_hash] EXPIRE #{key} seconds=#{seconds} success=#{result}"
122
- result
123
- rescue StandardError => e
124
- log_redis_error('expire', e)
125
- false
126
128
  end
127
129
 
128
130
  def log_redis_error(method, error)
131
+ Legion::Cache.send(:cache_backend_failed, error)
129
132
  handle_exception(error, level: :warn, handled: true, operation: method)
130
133
  end
131
134
  end
@@ -81,6 +81,14 @@ module Legion
81
81
 
82
82
  DEFAULT_PORTS = { 'dalli' => 11_211, 'redis' => 6379 }.freeze
83
83
 
84
+ def self.connection_options(settings, overrides)
85
+ resolved = settings.merge(overrides)
86
+ resolved[:servers] = [] if overrides.key?(:server) && !overrides.key?(:servers)
87
+ resolved[:server] = nil if overrides.key?(:servers) && !overrides.key?(:server)
88
+ resolved[:cluster] = nil if (overrides.key?(:server) || overrides.key?(:servers)) && !overrides.key?(:cluster)
89
+ resolved
90
+ end
91
+
84
92
  def self.resolve_servers(driver:, server: nil, servers: [], port: nil)
85
93
  gem_driver = normalize_driver(driver)
86
94
  port ||= DEFAULT_PORTS.fetch(gem_driver, 11_211)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module Cache
5
- VERSION = '1.4.2'
5
+ VERSION = '1.4.3'
6
6
  end
7
7
  end