legion-cache 1.4.1 → 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,23 +84,47 @@ 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
114
+ result
115
+ end
116
+
117
+ def set_nx(key, value, ttl: nil)
118
+ effective_ttl = redis_ttl(ttl)
119
+ serialized = serialize_value(value)
120
+ args = effective_ttl ? { ex: effective_ttl } : {}
121
+ result = client.with { |conn| conn.set(redis_key(key), serialized, nx: true, **args) == 'OK' }
122
+ log.debug { "[cache] SET_NX #{key} ttl=#{effective_ttl.inspect} result=#{result}" }
100
123
  result
124
+ rescue StandardError => e
125
+ cache_backend_failed(e)
126
+ handle_exception(e, level: :error, handled: false, operation: :redis_set_nx, key: key, ttl: effective_ttl)
127
+ raise
101
128
  end
102
129
 
103
130
  def set(key, value, ttl: nil, **)
@@ -105,42 +132,77 @@ module Legion
105
132
  end
106
133
 
107
134
  def set_sync(key, value, ttl: nil, **)
108
- effective_ttl = ttl || default_ttl
135
+ effective_ttl = redis_ttl(ttl)
109
136
  args = {}
110
137
  args[:ex] = effective_ttl unless effective_ttl.nil?
111
138
  serialized = serialize_value(value)
112
- result = client.with { |conn| conn.set(key, serialized, **args) == 'OK' }
139
+ result = client.with { |conn| conn.set(redis_key(key), serialized, **args) == 'OK' }
113
140
  log.debug { "[cache] SET #{key} ttl=#{effective_ttl.inspect} success=#{result}" }
114
141
  result
115
142
  rescue StandardError => e
143
+ cache_backend_failed(e)
116
144
  handle_exception(e, level: :error, handled: false, operation: :redis_set_sync, key: key, ttl: effective_ttl)
117
145
  raise
118
146
  end
119
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
+
120
179
  def delete(key, **)
121
180
  delete_sync(key)
122
181
  end
123
182
 
124
183
  def delete_sync(key)
125
- result = client.with { |conn| conn.del(key) == 1 }
184
+ result = client.with { |conn| conn.del(redis_key(key)) == 1 }
126
185
  log.debug { "[cache] DELETE #{key} success=#{result}" }
127
186
  result
128
187
  rescue StandardError => e
188
+ cache_backend_failed(e)
129
189
  handle_exception(e, level: :error, handled: false, operation: :redis_delete_sync, key: key)
130
190
  raise
131
191
  end
132
192
 
133
193
  def flush
134
194
  result = client.with do |conn|
135
- if cluster_mode?
136
- cluster_flush(conn)
137
- else
138
- conn.flushdb == 'OK'
139
- 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
140
201
  end
141
202
  log.debug { '[cache] FLUSH completed' }
142
203
  result
143
204
  rescue StandardError => e
205
+ cache_backend_failed(e)
144
206
  handle_exception(e, level: :warn, handled: true, operation: :redis_flush)
145
207
  nil
146
208
  end
@@ -149,18 +211,19 @@ module Legion
149
211
  keys = keys.flatten
150
212
  return {} if keys.empty?
151
213
 
152
- result = client.with do |conn|
214
+ qualified = keys.map { |key| redis_key(key) }
215
+ values = client.with do |conn|
153
216
  if cluster_mode?
154
- cluster_mget(conn, keys)
217
+ cluster_mget(conn, qualified).values_at(*qualified)
155
218
  else
156
- values = conn.mget(*keys)
157
- keys.zip(values).to_h
219
+ conn.mget(*qualified)
158
220
  end
159
221
  end
160
- result = result.transform_values { |v| deserialize_value(v) }
222
+ result = keys.zip(values.map { |value| deserialize_value(value) }).to_h
161
223
  log.debug { "[cache] MGET keys=#{keys.size}" }
162
224
  result
163
225
  rescue StandardError => e
226
+ cache_backend_failed(e)
164
227
  handle_exception(e, level: :warn, handled: true, operation: :redis_mget, key_count: keys.size)
165
228
  {}
166
229
  end
@@ -209,12 +272,16 @@ module Legion
209
272
  raw
210
273
  end
211
274
 
212
- def default_ttl
213
- return 3600 unless defined?(Legion::Settings)
275
+ def redis_settings
276
+ return @redis_settings if @redis_settings
277
+ return @cache_settings if @cache_settings
214
278
 
215
- Legion::Settings.dig(:cache, :default_ttl) || 3600
216
- rescue StandardError
217
- 3600
279
+ shared = defined?(Legion::Settings) ? Legion::Settings[:cache] : nil
280
+ Legion::Cache::Settings.default.merge(shared || {})
281
+ end
282
+
283
+ def default_ttl
284
+ redis_settings[:default_ttl]
218
285
  end
219
286
 
220
287
  def cluster_mget(conn, keys)
@@ -227,61 +294,34 @@ module Legion
227
294
  result
228
295
  end
229
296
 
230
- def cluster_mset(conn, hash)
231
- groups = group_keys_by_slot(hash.keys)
232
- groups.each_value do |group_keys|
233
- pairs = group_keys.flat_map { |k| [k, hash[k]] }
234
- conn.mset(*pairs)
235
- end
236
- true
237
- end
238
-
239
- def cluster_flush(conn)
240
- node_info = conn.cluster('nodes')
241
- primaries = node_info.lines.select { |l| l.include?('master') }.map { |l| l.split[1].split('@').first }
242
- primaries.each do |addr|
243
- host, port = Legion::Cache::Settings.parse_server_address(addr, default_port: 6379)
244
- node = ::Redis.new(host: host, port: port.to_i, **(@connection_opts || {}))
245
- node.flushdb
246
- node.close
247
- end
248
- true
249
- rescue StandardError => e
250
- handle_exception(e, level: :warn, handled: true, operation: :cluster_flush, fallback: :single_flushdb)
251
- conn.flushdb == 'OK'
252
- end
253
-
254
297
  def group_keys_by_slot(keys)
255
- if defined?(::Redis::Cluster::KeySlotConverter)
256
- keys.group_by { |k| ::Redis::Cluster::KeySlotConverter.convert(k) }
257
- else
258
- { 0 => keys }
259
- end
298
+ keys.group_by { |key| ::RedisClient::Cluster::KeySlotConverter.convert(key) }
260
299
  end
261
300
 
262
- def resolved_redis_address(server:, servers:, cluster:)
263
- nodes = Array(cluster).compact
264
- return nodes.join(', ') if nodes.any?
301
+ def redis_ttl(ttl)
302
+ value = ttl.nil? ? default_ttl : ttl
303
+ return nil if value.nil?
265
304
 
266
- Legion::Cache::Settings.resolve_servers(driver: 'redis', server: server, servers: Array(servers)).first
267
- rescue StandardError => e
268
- handle_exception(e, level: :warn, handled: true, operation: :resolved_redis_address)
269
- 'unknown'
305
+ raise ArgumentError, 'TTL must not be negative' if value.negative?
306
+
307
+ value.zero? ? nil : value
270
308
  end
271
309
 
272
310
  def resolve_primary_port(server: nil, servers: [], cluster: nil)
273
- nodes = Array(cluster).compact
274
- 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://')
275
314
 
276
- resolved = Legion::Cache::Settings.resolve_servers(driver: 'redis', server: server, servers: Array(servers))
277
- _, port = Legion::Cache::Settings.parse_server_address(resolved.first, default_port: 6379)
278
- port.to_i
279
- rescue StandardError
280
- 6379
315
+ Legion::Cache::Settings.parse_server_address(address, default_port: 6379).last
281
316
  end
282
317
 
283
318
  def redis_tls_options(port:)
284
- 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
285
325
 
286
326
  tls = Legion::Crypt::TLS.resolve(cache_tls_settings, port: port)
287
327
  return {} unless tls[:enabled]
@@ -290,17 +330,14 @@ module Legion
290
330
  verify_mode: tls[:verify] == :none ? OpenSSL::SSL::VERIFY_NONE : OpenSSL::SSL::VERIFY_PEER
291
331
  }
292
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]
293
335
 
294
336
  { ssl: true, ssl_params: ssl_params }
295
337
  end
296
338
 
297
339
  def cache_tls_settings
298
- return {} unless defined?(Legion::Settings)
299
-
300
- Legion::Settings[:cache][:tls] || {}
301
- rescue StandardError => e
302
- handle_exception(e, level: :warn, handled: true, operation: :cache_tls_settings)
303
- {}
340
+ redis_settings[:tls] || {}
304
341
  end
305
342
  end
306
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.1'
5
+ VERSION = '1.4.3'
6
6
  end
7
7
  end